Skip to content

Commit

Permalink
[BSVR-71] 로그인/회원가입, JWT 구현 (#34)
Browse files Browse the repository at this point in the history
* build : security 의존성 추가

* feat : security config 추가

* docs : jwt 시크릿 키 추가

* build : jwt 사용을 위한 라이브러리 추가

* feat : jwtFilter 등록

* feat : jwt 구현

* feat : jwtFilter 구현

* docs : jwt 구현

* docs : 카카오 Http 요청을 위한 webflux 추가

* feat : member 도메인 구현

* feat : memberEntity 구현

* feat : 카카오 엑세스 토큰, 유저 정보 가져오기 구현

* feat : 멤버 Role 관리를 위한 enum 구현

* remove : 초기 세팅 테스트용으로 작성된 코드 제거

* feat : 닉네임 중복 확인 구현

* feat : 닉네임 중복 확인 구현

* feat : 닉네임 중복 에러 구현

* refactor : 카카오 계정 정보 가져오는 로직 변경

* feat : 회원가입, 로그인 구현

* feat : 회원가입, 로그인 구현

* feat : id 토큰을 이용한 로그인 구현

* refactor : email과 이름, 전화번호, 레벨 권한 문제로 인해 nullable 처리

* refactor : url과 clientId 키 값 분리

* feat : Authentication를 상속받은 jwt 토큰 구현

* refactor : 권한 문제로 인해 idToken값 비교로 변경

* docs : oauth를 위한 환경 변수 추가

* fix : SecretKey 타입 차이로 인한 공백 오류 해결

* refactor : 로그인, 회원가입 JWT 사용해서 구현

* feat : 빌더 패턴 추가

* refactor : Member 변경에 따른 get 메소드 변경

* fix : spotless 포맷팅 오류 해결

* fix : 닉네임 중복 Exception 수정

* fix : swagger jwtFilter 오류 해결

* fix : 기존 회원 정보가 없을 경우 발생하는 에러 수정

* refactor : JwtFilter 개발 환경에서 모든 api 허용

* fix : my_team nullable로 인한 오류 해결(추 후 해당 필드와 컬럼 삭제 예정)

* feat: jwt 파일 분리

* feat: gitignore update

* feat: update gitignore

* Delete application/src/main/resources/application-jwt.yml

* feat: add kakao yml to gitignore

* fix: properties mapping 이슈 해결

---------

Co-authored-by: EunjiShin <eunji980310@gmail.com>
Co-authored-by: 우디 <38103085+EunjiShin@users.noreply.github.com>
  • Loading branch information
3 people authored Jul 18, 2024
1 parent 1ae7458 commit 50dcd96
Show file tree
Hide file tree
Showing 27 changed files with 743 additions and 92 deletions.
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -382,4 +382,8 @@ gradle-app.setting
# End of https://www.toptal.com/developers/gitignore/api/macos,windows,intellij,intellij+iml,intellij+all,visualstudiocode,java,gradle,kotlin
/db/

.env
.env

*.application-jwt.yml
application-jwt.yml
application-kakao.yml
4 changes: 3 additions & 1 deletion application/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ bin/
.vscode/

### Mac OS ###
.DS_Store
.DS_Store

*.application-jwt.yml
10 changes: 10 additions & 0 deletions application/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,18 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework:spring-aspects")

// security
implementation("org.springframework.boot:spring-boot-starter-security")

// swagger
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:_")

// jwt
// implementation("io.jsonwebtoken:jjwt:_")
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
implementation("io.jsonwebtoken:jjwt-jackson:0.11.5")

}

// spring boot main application이므로 실행 가능한 jar를 생성한다.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.depromeet.spot.application.common.config;

import org.depromeet.spot.application.common.jwt.JwtAuthenticationFilter;
import org.depromeet.spot.application.common.jwt.JwtTokenUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import lombok.RequiredArgsConstructor;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtTokenUtil jwtTokenUtil;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// cross-site -> stateless라서 필요 없음.
.csrf(AbstractHttpConfigurer::disable)
// 초기 로그인 화면 필요 없음.
.formLogin(AbstractHttpConfigurer::disable)
// 토큰 방식을 사용하므로 httpBasic도 제거.
.httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(
authorize ->
authorize
// 테스트, 개발 중엔 모든 경로 오픈.
.requestMatchers("/**")
.permitAll())
// UsernamePasswordAuthenticationFilter 필터 전에 jwt 필터가 먼저 동작하도록함.
.addFilterBefore(
new JwtAuthenticationFilter(jwtTokenUtil),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package org.depromeet.spot.application.common.jwt;

import java.io.IOException;
import java.util.List;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.depromeet.spot.domain.member.enums.MemberRole;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.server.ResponseStatusException;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@RequiredArgsConstructor
@Slf4j
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtTokenUtil jwtTokenUtil;

@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {

List<String> list =
List.of(
// swagger-ui와 v3/api-docs는 스웨거를 제외하기 위해 등록.
// 혹시나 스웨거 자원 사용 에러 발생 시 아래 두 가지 추가 필요함.
// Swagger UI에서 사용하는 외부 라ㅇ이브러리 제공 엔드포인트 : "/webjars/**"
// Swagger UI에서 사용하는 리소스 제공 엔드포인트 : "/swagger-resources/**"
// 로그인, 회원가입은 제외
"/swagger-ui", "/v3/api-docs", "/api/v1/members", "/kakao/", "/api/v1/");

// 현재 URL 이 LIST 안에 포함되있는걸로 시작하는가?
boolean flag = list.stream().anyMatch(url -> request.getRequestURI().startsWith(url));

if (flag) {
filterChain.doFilter(request, response);
return;
}

String header = request.getHeader(HttpHeaders.AUTHORIZATION);
log.info("JwtAuthenticationFilter header : {}", header);

// header가 null이거나 빈 문자열이면 안됨.
if (header != null && !header.equalsIgnoreCase("")) {
if (header.startsWith("Bearer")) {
String access_token = header.split(" ")[1];
if (jwtTokenUtil.isValidateToken(access_token)) {
String memberId = jwtTokenUtil.getIdFromJWT(access_token);
MemberRole role = MemberRole.valueOf(jwtTokenUtil.getRoleFromJWT(access_token));
JwtToken jwtToken = new JwtToken(memberId, role);
SecurityContextHolder.getContext().setAuthentication(jwtToken);
filterChain.doFilter(request, response);
}
}
// 토큰 검증 실패 -> Exception
} else throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package org.depromeet.spot.application.common.jwt;

import java.util.Collection;
import java.util.List;

import org.depromeet.spot.domain.member.enums.MemberRole;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class JwtToken implements Authentication {
// TODO : Authentication을 상속받고 UserDetail을 상속받은 커스텀 유저 정보 객체 생성해줘야함.
private String memberId;
private MemberRole memberRole;

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}

@Override
public Object getCredentials() {
return null;
}

@Override
public Object getDetails() {
return null;
}

@Override
public Object getPrincipal() {
return null;
}

@Override
public boolean isAuthenticated() {
return false;
}

@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {}

@Override
public String getName() {
return "";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package org.depromeet.spot.application.common.jwt;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import javax.crypto.spec.SecretKeySpec;

import org.depromeet.spot.domain.member.Member;
import org.depromeet.spot.domain.member.enums.MemberRole;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.WeakKeyException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtTokenUtil {
// JWT를 생성하고 관리하는 클래스

// 토큰에 사용되는 시크릿 키
@Value("${spring.jwt.secret}")
private String SECRETKEY;

public HttpHeaders getJWTToken(Member member) {
// TODO 토큰 구현하기.

// jwt 토큰 생성
String token = generateToken(member.getId(), member.getRole());

HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, "Bearer " + token);
return headers;
}

public String generateToken(Long memberId, MemberRole memberRole) {
return Jwts.builder()
.setHeader(createHeader())
.setClaims(createClaims(memberRole))
.setSubject(memberId.toString())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(
new Date(
System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 30L)) // 토큰 만료 시간
.signWith(SignatureAlgorithm.HS256, SECRETKEY.getBytes())
.compact();
}

public String getIdFromJWT(String token) {
return Jwts.parser()
.setSigningKey(SECRETKEY.getBytes())
.parseClaimsJws(token)
.getBody()
.get("id", String.class);
}

public String getRoleFromJWT(String token) {
return Jwts.parser()
.setSigningKey(SECRETKEY.getBytes())
.parseClaimsJws(token)
.getBody()
.get("role", String.class);
}

public Jws<Claims> getClaims(String token) {
return Jwts.parserBuilder().setSigningKey(createSignature()).build().parseClaimsJws(token);
}

public boolean isValidateToken(String token) {
try {
Jws<Claims> claims = getClaims(token);
return true;
} catch (ExpiredJwtException exception) {
log.error("Token Expired");
throw new ExpiredJwtException(exception.getHeader(), exception.getClaims(), token);
} catch (UnsupportedJwtException | WeakKeyException exception) {
log.error("Unsupported Token");
throw new UnsupportedJwtException("지원되지 않는 토큰입니다.");
} catch (MalformedJwtException | IllegalArgumentException exception) {
throw new MalformedJwtException("잘못된 형식의 토큰입니다.");
}
}

private Map<String, Object> createHeader() {
// 헤더 생성
Map<String, Object> headers = new HashMap<>();

headers.put("typ", "JWT");
headers.put("alg", "HS256"); // 서명? 생성에 사용될 알고리즘

return headers;
}

// Claim -> 정보를 key-value 형태로 저장함.
private Map<String, Object> createClaims(MemberRole role) {
Map<String, Object> claims = new HashMap<>();

claims.put("role", role);
return claims;
}

private Key createSignature() {
byte[] apiKeySecretBytes = SECRETKEY.getBytes();
return new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName());
}
}
Loading

0 comments on commit 50dcd96

Please sign in to comment.