Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 사용자 인증을 위한 jwt 인증 프로세스를 구현한다. #41

Merged
merged 19 commits into from
Nov 1, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
29690aa
chore: jwt 인증 구현을 위한 jjwt 라이브러리 추가
hseong3243 Oct 29, 2023
2ad70be
Merge branch 'dev' of https://github.com/Anifriends/Anifriends-Backen…
hseong3243 Oct 30, 2023
1c3c0ce
feat: jwt 템플릿 적용
hseong3243 Oct 31, 2023
9831973
chore: dev 브랜치 pull
hseong3243 Oct 31, 2023
b125e32
fix: 스프링 시큐리티 테스트로 인한 restDocs 오류 해결
hseong3243 Oct 31, 2023
d4df416
feat: 예측하지 못한 예외에 대한 예외 핸들러를 추가한다.
hseong3243 Oct 31, 2023
ceed5cf
test: 보호소 로그인 서비스 로직 테스트를 추가한다.
hseong3243 Oct 31, 2023
0440175
feat: 보호소 로그인 api를 추가한다.
hseong3243 Oct 31, 2023
b15ed42
Merge branch 'dev' of https://github.com/Anifriends/Anifriends-Backen…
hseong3243 Oct 31, 2023
b591002
feat: 적용한 jwt 템플릿에 프로젝트 커스텀 예외를 적용한다.
hseong3243 Oct 31, 2023
ee006c5
chore: jwt secret을 properties에서 제거
hseong3243 Oct 31, 2023
03d41cf
feat: 로그인 api 요청 바디가 공백일 시 반환하는 메시지를 추가한다.
hseong3243 Oct 31, 2023
424eaf9
fix: 충돌 해결
hseong3243 Oct 31, 2023
6db6967
fix: AuthService `@Transactional` 추가
hseong3243 Nov 1, 2023
d01f683
fix: 테스트용 환경 변수를 추가한다.
hseong3243 Nov 1, 2023
60901b9
fix: 충돌을 해결한다.
hseong3243 Nov 1, 2023
6d77cc7
test: LoginArgumentResolver가 적용되지 않는 상황의 테스트를 추가한다.
hseong3243 Nov 1, 2023
3efc5d5
style: 컨벤션에 맞게 메서드 이름 및 코드 끝에 개행을 추가한다.
hseong3243 Nov 1, 2023
835f1a0
test: 예외 테스트의 when, then을 좀 더 구분하기 쉽게 수정
hseong3243 Nov 1, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'

// DB
runtimeOnly 'com.h2database:h2'
Expand All @@ -74,6 +72,16 @@ dependencies {
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

//jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

//test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.awaitility:awaitility'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.clova.anifriends.domain.auth;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@Table(name = "refresh_token")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long refreshTokenId;

private String tokenValue;

public RefreshToken(String value) {
this.tokenValue = value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.clova.anifriends.domain.auth.authentication;

public record JwtAuthentication(Long memberId, String accessToken) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.clova.anifriends.domain.auth.authentication;

import com.clova.anifriends.domain.auth.jwt.JwtProvider;
import com.clova.anifriends.domain.auth.jwt.response.CustomClaims;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationProvider {

private final JwtProvider jwtProvider;

public Authentication authenticate(String accessToken) {
CustomClaims claims = jwtProvider.parseAccessToken(accessToken);
JwtAuthentication authentication = new JwtAuthentication(claims.memberId(), accessToken);
List<GrantedAuthority> authorities = getAuthorities(claims.authorities());
return UsernamePasswordAuthenticationToken.authenticated(authentication, accessToken,
authorities);
}

private List<GrantedAuthority> getAuthorities(List<String> authorities) {
return authorities.stream()
.map(SimpleGrantedAuthority::new)
.map(GrantedAuthority.class::cast)
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.clova.anifriends.domain.auth.controller;

import com.clova.anifriends.domain.auth.controller.request.LoginRequest;
import com.clova.anifriends.domain.auth.service.AuthService;
import com.clova.anifriends.domain.auth.service.response.TokenResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class AuthController {

private final AuthService authService;

@PostMapping("/volunteers/login")
public ResponseEntity<TokenResponse> volunteerLogin(@RequestBody @Valid LoginRequest request) {
TokenResponse response = authService.volunteerLogin(request.email(), request.password());
return ResponseEntity.ok(response);
}

@PostMapping("/shelters/login")
public ResponseEntity<TokenResponse> shelterLogin(@RequestBody @Valid LoginRequest request) {
TokenResponse response = authService.shelterLogin(request.email(), request.password());
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.clova.anifriends.domain.auth.controller.request;

import jakarta.validation.constraints.NotBlank;

public record LoginRequest(
@NotBlank
hseong3243 marked this conversation as resolved.
Show resolved Hide resolved
String email,
@NotBlank
String password) {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.clova.anifriends.domain.auth.exception;

import com.clova.anifriends.global.exception.AuthenticationException;
import com.clova.anifriends.global.exception.ErrorCode;

public class AuthAuthenticationException extends AuthenticationException {

public AuthAuthenticationException(ErrorCode errorCode, String message) {
super(errorCode, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.clova.anifriends.domain.auth.exception;

import com.clova.anifriends.global.exception.AuthenticationException;
import com.clova.anifriends.global.exception.ErrorCode;

public class ExpiredAccessTokenException extends AuthenticationException {

public ExpiredAccessTokenException(ErrorCode errorCode, String message) {
super(errorCode, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.clova.anifriends.domain.auth.exception;

import com.clova.anifriends.global.exception.AuthorizationException;
import com.clova.anifriends.global.exception.ErrorCode;

public class ExpiredRefreshTokenException extends AuthorizationException {

public ExpiredRefreshTokenException(ErrorCode errorCode, String message) {
super(errorCode, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.clova.anifriends.domain.auth.exception;

import com.clova.anifriends.global.exception.AuthenticationException;
import com.clova.anifriends.global.exception.ErrorCode;

public class InvalidJwtException extends AuthenticationException {

public InvalidJwtException(ErrorCode errorCode, String message) {
super(errorCode, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.clova.anifriends.domain.auth.filter;

import com.clova.anifriends.domain.auth.authentication.JwtAuthenticationProvider;
import jakarta.servlet.FilterChain;
import jakarta.servlet.GenericFilter;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilter {

private static final String HEADER = "Authorization";
private final JwtAuthenticationProvider authenticationProvider;

@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
String accessToken = request.getHeader(HEADER);
if (Objects.nonNull(accessToken)) {
Authentication authentication = authenticationProvider.authenticate(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(req, res);
}
}
122 changes: 122 additions & 0 deletions src/main/java/com/clova/anifriends/domain/auth/jwt/JJwtProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.clova.anifriends.domain.auth.jwt;

import com.clova.anifriends.domain.auth.exception.ExpiredAccessTokenException;
import com.clova.anifriends.domain.auth.exception.InvalidJwtException;
import com.clova.anifriends.domain.auth.exception.ExpiredRefreshTokenException;
import com.clova.anifriends.domain.auth.jwt.response.CustomClaims;
import com.clova.anifriends.domain.auth.jwt.response.UserToken;
import com.clova.anifriends.global.exception.ErrorCode;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import javax.crypto.SecretKey;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class JJwtProvider implements JwtProvider {

private static final String ROLE = "role";

private final String issuer;
private final int expirySeconds;
private final int refreshExpirySeconds;
private final SecretKey secretKey;
private final SecretKey refreshSecretKey;
private final JwtParser accessTokenParser;
private final JwtParser refreshTokenParser;


public JJwtProvider(
@Value("${jwt.issuer}") String issuer,
@Value("${jwt.expiry-seconds}") int expirySeconds,
@Value("${jwt.refresh-expiry-seconds}") int refreshExpirySeconds,
@Value("${jwt.secret}") String secret,
@Value("${jwt.refresh-secret}") String refreshSecret) {
this.issuer = issuer;
this.expirySeconds = expirySeconds;
this.refreshExpirySeconds = refreshExpirySeconds;
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.refreshSecretKey = Keys.hmacShaKeyFor(refreshSecret.getBytes(StandardCharsets.UTF_8));
this.accessTokenParser = Jwts.parser()
.verifyWith(secretKey)
.build();
this.refreshTokenParser = Jwts.parser()
.verifyWith(refreshSecretKey)
.build();
}

@Override
public UserToken createToken(Long userId, UserRole userRole) {
String accessToken = createAccessToken(userId, userRole);
String refreshToken = createRefreshToken(userId, userRole);
return UserToken.of(accessToken, refreshToken);
}

private String createAccessToken(Long userId, UserRole userRole) {
Date now = new Date();
Date expiresAt = new Date(now.getTime() + expirySeconds * 1000L);
return Jwts.builder()
.issuer(issuer)
.issuedAt(now)
.subject(userId.toString())
.expiration(expiresAt)
.claim(ROLE, userRole.getValue())
.signWith(secretKey)
.compact();
}

private String createRefreshToken(Long userId, UserRole userRole) {
Date now = new Date();
Date expiresAt = new Date(now.getTime() + refreshExpirySeconds * 1000L);
return Jwts.builder()
.issuer(issuer)
.issuedAt(now)
.subject(userId.toString())
.expiration(expiresAt)
.claim(ROLE, userRole.getValue())
.signWith(refreshSecretKey)
.compact();
}

@Override
public CustomClaims parseAccessToken(String token) {
try {
Claims claims = accessTokenParser.parseSignedClaims(token).getPayload();
Long userId = Long.valueOf(claims.getSubject());
String userRole = claims.get(ROLE, String.class);
List<String> authorities = UserRole.valueOf(userRole).getAuthorities();
return CustomClaims.of(userId, authorities);
} catch (ExpiredJwtException ex) {
log.info("[EX] {}: 만료된 JWT입니다.", ex.getClass().getSimpleName());
throw new ExpiredAccessTokenException(ErrorCode.TOKEN_EXPIRED, "만료된 액세스 토큰입니다.");
} catch (JwtException ex) {
log.info("[EX] {}: 잘못된 JWT입니다.", ex.getClass().getSimpleName());
}
throw new InvalidJwtException(ErrorCode.UN_AUTHENTICATION, "유효하지 않은 JWT입니다.");
}

@Override
public UserToken refreshAccessToken(String refreshToken) {
try {
Claims claims = refreshTokenParser.parseSignedClaims(refreshToken).getPayload();
Long userId = Long.valueOf(claims.getSubject());
UserRole userRole = UserRole.valueOf(claims.get(ROLE, String.class));
return createToken(userId, userRole);
} catch (ExpiredJwtException ex) {
log.info("[EX] {}: 만료된 리프레시 토큰입니다.", ex.getClass().getSimpleName());
throw new ExpiredRefreshTokenException(ErrorCode.TOKEN_EXPIRED, "만료된 리프레시 토큰입니다.");
} catch (JwtException ex) {
log.info("[EX] {}: 잘못된 리프레시 토큰입니다.", ex.getClass().getSimpleName());
}
throw new InvalidJwtException(ErrorCode.UN_AUTHENTICATION, "유효하지 않은 리프레시 토큰입니다.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.clova.anifriends.domain.auth.jwt;


import com.clova.anifriends.domain.auth.jwt.response.CustomClaims;
import com.clova.anifriends.domain.auth.jwt.response.UserToken;

public interface JwtProvider {

UserToken createToken(Long userId, UserRole userRole);

CustomClaims parseAccessToken(String token);

UserToken refreshAccessToken(String refreshToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.clova.anifriends.domain.auth.jwt;

import com.clova.anifriends.EnumType;
import java.util.List;

public enum UserRole implements EnumType {
ROLE_VOLUNTEER(Constants.ROLE_VOLUNTEER, List.of(Constants.ROLE_VOLUNTEER)),
ROLE_SHELTER(Constants.ROLE_SHELTER, List.of(Constants.ROLE_SHELTER));

private final String value;
private final List<String> authorities;

UserRole(String value, List<String> authorities) {
this.value = value;
this.authorities = authorities;
}

@Override
public String getName() {
return name();
}

@Override
public String getValue() {
return value;
}

public List<String> getAuthorities() {
return authorities;
}

private static class Constants {
private static final String ROLE_VOLUNTEER = "ROLE_VOLUNTEER";
private static final String ROLE_SHELTER = "ROLE_SHELTER";
}
}
Loading
Loading