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: User authentication #41

Merged
merged 10 commits into from
Jul 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions app/api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ allprojects {
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.3.0'

implementation "org.springframework.boot:spring-boot-starter-web"
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation "org.springframework.boot:spring-boot-starter-test"
}
Expand Down
3 changes: 0 additions & 3 deletions app/api/common-api/build.gradle
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
dependencies {
implementation project(":app:domain:user-domain")

// spring-security
implementation 'org.springframework.boot:spring-boot-starter-security'

// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ private RequestMatcher getMatcherForUserAndAdmin() {
return RequestMatchers.anyOf(
antMatcher(HttpMethod.GET, "/api/v1/shows/interests"),
antMatcher(HttpMethod.POST, "/api/v1/users/logout"),
antMatcher(HttpMethod.POST, "/api/v1/users/withdrawal"),
antMatcher(HttpMethod.POST, "/api/v1/shows/**/interest"),
antMatcher(HttpMethod.POST, "/api/v1/shows/**/alert")
antMatcher(HttpMethod.POST, "/api/v1/shows/**/alert"),
antMatcher(HttpMethod.POST, "/api/v1/genres/**")
);
}
}
16 changes: 4 additions & 12 deletions app/api/common-api/src/main/java/org/example/filter/JWTFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,12 @@
import java.io.IOException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.example.exception.BusinessException;
import org.example.repository.TokenRepository;
import org.example.security.dto.AuthenticatedUser;
import org.example.security.dto.TokenParam;
import org.example.security.dto.UserParam;
import org.example.security.token.JWTHandler;
import org.example.security.token.RefreshTokenProcessor;
import org.example.security.vo.TokenError;
import org.example.security.token.TokenProcessor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
Expand All @@ -27,7 +25,7 @@
public class JWTFilter extends OncePerRequestFilter {

private final JWTHandler jwtHandler;
private final RefreshTokenProcessor refreshTokenProcessor;
private final TokenProcessor tokenProcessor;
private final TokenRepository tokenRepository;
GaBaljaintheroom marked this conversation as resolved.
Show resolved Hide resolved

@Override
Expand All @@ -37,7 +35,7 @@ protected void doFilterInternal(
FilterChain filterChain
) throws ServletException, IOException {
if (request.getHeader("Refresh") != null) {
TokenParam token = refreshTokenProcessor.reissueToken(request);
TokenParam token = tokenProcessor.reissueToken(request);
response.getWriter().write(new ObjectMapper().writeValueAsString(token));
return;
}
Expand All @@ -52,16 +50,10 @@ protected void doFilterInternal(
private void handleAccessToken(HttpServletRequest request) {
String accessToken = jwtHandler.extractAccessToken(request);
UserParam userParam = jwtHandler.extractUserFrom(accessToken);
verifyLogoutAccessToken(userParam);
tokenProcessor.verifyAccessTokenBlacklist(userParam, accessToken);
saveOnSecurityContextHolder(userParam);
}

public void verifyLogoutAccessToken(UserParam userParam) {
if (tokenRepository.existAccessToken(userParam.userId().toString())) {
throw new BusinessException(TokenError.INVALID_TOKEN);
}
}

private void saveOnSecurityContextHolder(UserParam userParam) {
AuthenticatedUser authenticatedUser = AuthenticatedUser.builder()
.userId(userParam.userId())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package org.example.repository;

import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Component;

@Component
public interface TokenRepository {

void save(String userId, String refreshToken);
void saveBlacklistAccessToken(UUID userId, String accessToken);

void saveRefreshToken(UUID userId, String refreshToken);

Optional<String> getExistRefreshToken(String userId);

Boolean existAccessToken(String userId);
boolean existAccessTokenInBlacklist(UUID userId, String accessToken);

void deleteRefreshToken(UUID userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.Map;
import java.util.UUID;
import lombok.Builder;
import org.example.entity.User;
import org.example.vo.UserRoleApiType;

@Builder
Expand All @@ -11,13 +12,6 @@ public record UserParam(
UserRoleApiType role
) {

public Map<String, String> getTokenClaim() {
return Map.of(
"userId", userId.toString(),
"role", role.name()
);
}

public static UserParam fromPayload(Object payload) {
Map<String, String> claim = (Map<String, String>) payload;

Expand All @@ -26,4 +20,18 @@ public static UserParam fromPayload(Object payload) {
.role(UserRoleApiType.valueOf(claim.get("role")))
.build();
}

public static UserParam from(User user) {
return UserParam.builder()
.userId(user.getId())
.role(UserRoleApiType.from(user.getUserRole()))
.build();
}

public Map<String, String> getTokenClaim() {
return Map.of(
"userId", userId.toString(),
"role", role.name()
);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package org.example.security.vo;
package org.example.security.error;

import org.example.exception.BusinessError;

Expand Down Expand Up @@ -78,7 +78,7 @@ public int getHttpStatus() {

@Override
public String getErrorCode() {
return "TKN-003";
return "TKN-004";
}

@Override
Expand All @@ -100,7 +100,7 @@ public int getHttpStatus() {

@Override
public String getErrorCode() {
return "TKN-004";
return "TKN-005";
}

@Override
Expand All @@ -112,5 +112,27 @@ public String getClientMessage() {
public String getLogMessage() {
return "λ§Œλ£Œλ˜μ§€ μ•Šμ€ 토큰에 λŒ€ν•΄, 만료 상황에 λŒ€ν•œ 둜직이 μ‹œν–‰λ˜μ—ˆμŠ΅λ‹ˆλ‹€.";
}
},

BLACKLIST_ACCESS_TOKEN {
@Override
public int getHttpStatus() {
return 401;
}

@Override
public String getErrorCode() {
return "TKN-006";
}

@Override
public String getClientMessage() {
return "μœ νš¨ν•˜μ§€ μ•Šμ€ ν† ν°μž…λ‹ˆλ‹€.";
}

@Override
public String getLogMessage() {
return "λΈ”λž™λ¦¬μŠ€νŠΈμ— λ“±λ‘λœ ν† ν°μž…λ‹ˆλ‹€.";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@
import io.jsonwebtoken.Jwts;
import java.util.Date;
import lombok.RequiredArgsConstructor;
import org.example.exception.BusinessException;
import org.example.property.TokenProperty;
import org.example.repository.TokenRepository;
import org.example.security.dto.TokenParam;
import org.example.security.dto.UserParam;
import org.example.security.vo.TokenError;
import org.springframework.stereotype.Component;

@Component
Expand All @@ -24,7 +22,7 @@ public TokenParam generate(UserParam userParam, Date from) {
.refreshToken(createRefreshToken(userParam, from))
.build();

tokenRepository.save(userParam.userId().toString(), tokenParam.refreshToken());
tokenRepository.saveRefreshToken(userParam.userId(), tokenParam.refreshToken());
return tokenParam;
}

Expand All @@ -49,10 +47,4 @@ private String createRefreshToken(UserParam userParam, Date from) {
.signWith(tokenProperty.getBase64URLSecretKey())
.compact();
}

public String getExistRefreshToken(UserParam userParam) {
return tokenRepository.getExistRefreshToken(userParam.userId().toString())
.orElseThrow(() -> new BusinessException(TokenError.WRONG_HEADER));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import org.example.exception.BusinessException;
import org.example.property.TokenProperty;
import org.example.security.dto.UserParam;
import org.example.security.vo.TokenError;
import org.example.security.error.TokenError;
import org.springframework.stereotype.Component;

@Component
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package org.example.security.token;

import jakarta.servlet.http.HttpServletRequest;
import java.util.Date;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.example.exception.BusinessException;
import org.example.repository.TokenRepository;
import org.example.security.dto.TokenParam;
import org.example.security.dto.UserParam;
import org.example.security.error.TokenError;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class TokenProcessor {

private final JWTHandler jwtHandler;
private final JWTGenerator jwtGenerator;
private final TokenRepository tokenRepository;

public TokenParam reissueToken(HttpServletRequest request) {
String refreshToken = jwtHandler.extractRefreshToken(request);
UserParam userParam = jwtHandler.extractUserFrom(refreshToken);

String oldRefreshToken = getExistRefreshToken(userParam);
if (!refreshToken.equals(oldRefreshToken)) {
throw new BusinessException(TokenError.INVALID_TOKEN);
}

return jwtGenerator.generate(userParam, new Date());
}

public void verifyAccessTokenBlacklist(UserParam userParam, String accessKey) {
if (tokenRepository.existAccessTokenInBlacklist(userParam.userId(), accessKey)) {
throw new BusinessException(TokenError.BLACKLIST_ACCESS_TOKEN);
}
}

public void makeAccessTokenBlacklistAndDeleteRefreshToken(
String accessToken,
UUID userId
) {
tokenRepository.saveBlacklistAccessToken(userId, accessToken);
tokenRepository.deleteRefreshToken(userId);
}

private String getExistRefreshToken(UserParam userParam) {
return tokenRepository.getExistRefreshToken(userParam.userId().toString())
.orElseThrow(() -> new BusinessException(TokenError.WRONG_HEADER));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import org.example.repository.TokenRepository;
import org.example.security.dto.TokenParam;
import org.example.security.dto.UserParam;
import org.example.security.vo.TokenError;
import org.example.security.error.TokenError;
import org.example.vo.UserRoleApiType;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@
import lombok.RequiredArgsConstructor;
import org.example.controller.dto.request.LoginApiRequest;
import org.example.controller.dto.request.LogoutApiRequest;
import org.example.controller.dto.request.WithdrawalApiRequest;
import org.example.controller.dto.response.LoginApiResponse;
import org.example.security.dto.AuthenticatedUser;
import org.example.security.dto.TokenParam;
import org.example.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -25,14 +29,33 @@ public class UserController {
@PostMapping("/login")
@Operation(summary = "둜그인", description = "νšŒμ›κ°€μž… / 둜그인")
public ResponseEntity<LoginApiResponse> signUp(@Valid @RequestBody LoginApiRequest request) {
return ResponseEntity.ok(new LoginApiResponse(
"accessToken", "refreshToken"
));
TokenParam token = userService.login(request.toServiceType());

return ResponseEntity.ok(
LoginApiResponse.builder()
.accessToken(token.accessToken())
.refreshToken(token.refreshToken())
.build()
);
}

@PostMapping("/logout")
@Operation(summary = "λ‘œκ·Έμ•„μ›ƒ")
public ResponseEntity<Void> logout(@Valid @RequestBody LogoutApiRequest request) {
public ResponseEntity<Void> logout(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestBody LogoutApiRequest request
) {
userService.logout(request.toServiceRequest(user.userId()));
return ResponseEntity.noContent().build();
}

@PostMapping("/withdrawal")
@Operation(summary = "νšŒμ›νƒˆν‡΄")
public ResponseEntity<Void> withdraw(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestBody WithdrawalApiRequest request
) {
userService.withdraw(request.toServiceRequest(user.userId()));
return ResponseEntity.noContent().build();
}
}
Loading