Skip to content

Commit

Permalink
feat: JWT 인증 및 시큐리티 설정 추가 (#20)
Browse files Browse the repository at this point in the history
* feat: jwt 기본 설정 추가

* feat: security config 설정 추가

* feat: userId 관련 로직 추가
  • Loading branch information
hanbirang authored Oct 31, 2024
1 parent 91d5084 commit 5a970c0
Show file tree
Hide file tree
Showing 11 changed files with 420 additions and 0 deletions.
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'

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

//db
implementation 'org.flywaydb:flyway-mysql'
runtimeOnly 'com.mysql:mysql-connector-j'
Expand Down
56 changes: 56 additions & 0 deletions src/main/java/site/sonisori/sonisori/auth/cookie/CookieUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package site.sonisori.sonisori.auth.cookie;

import java.util.Arrays;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;

public class CookieUtil {
@Value("${spring.jwt.refresh-expiration}")
private final long refreshExpiration;

public CookieUtil(long refreshExpiration) {
this.refreshExpiration = refreshExpiration;
}

public ResponseCookie createCookie(String cookieName, String cookieValue, String domain) {
return ResponseCookie.from(cookieName, cookieValue)
.domain(domain)
.path("/")
.httpOnly(true)
.secure(true)
.sameSite("None")
.maxAge(refreshExpiration)
.build();
}

public String getCookieValue(HttpServletRequest request, String cookieName) {
if (request == null || cookieName == null) {
throw new IllegalArgumentException();
}
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}

return Arrays.stream(cookies)
.filter(cookie -> cookieName.equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse(null);
}

public ResponseCookie clearCookie(String cookieName, String domain) {
return ResponseCookie.from(cookieName, "")
.domain(domain)
.path("/")
.httpOnly(true)
.secure(true)
.sameSite("None")
.maxAge(0)
.build();
}
}
106 changes: 106 additions & 0 deletions src/main/java/site/sonisori/sonisori/auth/jwt/JwtUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package site.sonisori.sonisori.auth.jwt;

import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.UUID;

import javax.crypto.SecretKey;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import site.sonisori.sonisori.auth.jwt.dto.TokenDto;
import site.sonisori.sonisori.auth.jwt.entity.RefreshToken;
import site.sonisori.sonisori.auth.jwt.repository.RefreshTokenRepository;
import site.sonisori.sonisori.common.constants.ErrorMessage;
import site.sonisori.sonisori.entity.User;

@Component
public class JwtUtil {
private static final int MILLIS = 1000;
private final RefreshTokenRepository refreshTokenRepository;
private final String issuer;
private final long accessTokenExpiration;
private final long refreshTokenExpiration;
private final SecretKey secretKey;

public JwtUtil(RefreshTokenRepository refreshTokenRepository,
@Value("${spring.jwt.secret-key}") String secret,
@Value("${spring.application.name}") String issuer,
@Value("${spring.jwt.access-expiration}") long accessTokenExpiration,
@Value("${spring.jwt.refresh-expiration}") long refreshTokenExpiration) {
this.refreshTokenRepository = refreshTokenRepository;
this.issuer = issuer;
this.accessTokenExpiration = accessTokenExpiration * MILLIS;
this.refreshTokenExpiration = refreshTokenExpiration * MILLIS;
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}

public String createAccessToken(User user) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenExpiration);

return Jwts.builder()
.issuer(issuer)
.issuedAt(now)
.subject(String.valueOf(user.getId()))
.claim("token_type", "access_token")
.claim("role", user.getRole())
.expiration(expiryDate)
.signWith(secretKey)
.compact();
}

public String createRefreshToken(User user) {
RefreshToken refreshToken = new RefreshToken(UUID.randomUUID().toString(), user.getId());
refreshTokenRepository.save(refreshToken);
return refreshToken.getRefreshToken();
}

public TokenDto generateJwt(User user) {
String accessToken = createAccessToken(user);
String refreshToken = createRefreshToken(user);

long currentMillis = System.currentTimeMillis();

return TokenDto.builder()
.accessToken(accessToken)
.accessTokenExpiresIn(currentMillis + accessTokenExpiration)
.refreshToken(refreshToken)
.refreshTokenExpiresIn(currentMillis + refreshTokenExpiration)
.build();
}

public Claims extractClaims(String accessToken) {
try {
return Jwts.parser().verifyWith(secretKey).build()
.parseSignedClaims(accessToken).getPayload();
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public boolean validateAccessToken(String accessToken) {
try {
Claims claims = extractClaims(accessToken);
if (!claims.get("token_type").equals("access_token") || accessToken == null) {
throw new JwtException(ErrorMessage.NOT_FOUND_TOKEN.getMessage());
}
return !claims.getExpiration().before(new Date());
} catch (ExpiredJwtException e) {
throw new JwtException(ErrorMessage.NOT_FOUND_TOKEN.getMessage());
} catch (SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
throw new JwtException(ErrorMessage.INVALID_TOKEN.getMessage());
} catch (JwtException e) {
throw new JwtException(e.getMessage());
}
}
}
20 changes: 20 additions & 0 deletions src/main/java/site/sonisori/sonisori/auth/jwt/dto/TokenDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package site.sonisori.sonisori.auth.jwt.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.Builder;

@Builder
public record TokenDto(
@NotBlank
String accessToken,

@NotBlank
Long accessTokenExpiresIn,

@NotBlank
String refreshToken,

@NotBlank
Long refreshTokenExpiresIn
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package site.sonisori.sonisori.auth.jwt.exception;

import java.io.IOException;

import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import com.fasterxml.jackson.databind.ObjectMapper;

import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.SignatureException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import site.sonisori.sonisori.common.constants.ErrorMessage;
import site.sonisori.sonisori.common.response.ErrorResponse;

@Component
@RequiredArgsConstructor
public class JwtExceptionFilter extends OncePerRequestFilter {
private static final ObjectMapper objectMapper = new ObjectMapper();

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
response.setCharacterEncoding("utf-8");

try {
filterChain.doFilter(request, response);
} catch (ExpiredJwtException e) {
setErrorResponse(response, ErrorMessage.EXPIRED_TOKEN.getMessage());
} catch (SignatureException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
setErrorResponse(response, ErrorMessage.INVALID_TOKEN.getMessage());
} catch (JwtException e) {
setErrorResponse(response, ErrorMessage.NOT_FOUND_TOKEN.getMessage());
}
}

private void setErrorResponse(HttpServletResponse response, String message)
throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");

ErrorResponse errorResponse = new ErrorResponse(message);

String jsonResponse = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(jsonResponse);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package site.sonisori.sonisori.auth.oauth2;

import java.util.Collection;
import java.util.Collections;
import java.util.Map;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import site.sonisori.sonisori.auth.oauth2.dto.OAuth2UserDto;
import site.sonisori.sonisori.entity.User;

@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User, UserDetails {
private final OAuth2UserDto oAuth2UserDto;
@Getter
private final User user;
private Map<String, Object> attributes;

@Override
public Map<String, Object> getAttributes() {
return attributes;
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
String role = oAuth2UserDto.role();
return Collections.singletonList(new SimpleGrantedAuthority(role));
}

@Override
public String getPassword() {
return null;
}

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

public String getUsername() {
return oAuth2UserDto.username();
}

public Long getUserId() {
return user.getId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package site.sonisori.sonisori.auth.oauth2.dto;

public record OAuth2UserDto(
String name,
String username,
String role,
String email
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
public enum ErrorMessage {
INVALID_REQUEST("유효하지 않은 요청입니다."),
SERVER_ERROR("서버 오류가 발생했습니다."),
EXPIRED_TOKEN("토큰이 만료되었습니다."),
NOT_FOUND_TOKEN("토큰이 존재하지 않습니다."),
INVALID_TOKEN("유효하지 않은 토큰입니다."),
METHOD_VALIDATION_FAILED("메서드 유효성 검사에 실패했습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package site.sonisori.sonisori.config;

import java.io.IOException;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;

import com.fasterxml.jackson.databind.ObjectMapper;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import site.sonisori.sonisori.common.constants.ErrorMessage;
import site.sonisori.sonisori.common.response.ErrorResponse;

@Slf4j
@Component
@RequiredArgsConstructor
public class ExceptionHandlerConfig implements Customizer<ExceptionHandlingConfigurer<HttpSecurity>> {
private final ObjectMapper objectMapper;

@Override
public void customize(ExceptionHandlingConfigurer<HttpSecurity> httpSecurityExceptionHandlingConfigurer) {
httpSecurityExceptionHandlingConfigurer
.authenticationEntryPoint(this::handleAuthenticationException)
.accessDeniedHandler(this::handleAccessDeniedException);
}

private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
log.error("Unauthorized request - Method: {}, URI: {}, Error: {}",
request.getMethod(),
request.getRequestURI(),
authException.getMessage());

response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");

ErrorResponse errorResponse = new ErrorResponse(ErrorMessage.NOT_FOUND_TOKEN.getMessage());

String jsonResponse = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(jsonResponse);
}

private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
Loading

0 comments on commit 5a970c0

Please sign in to comment.