Skip to content

Commit

Permalink
merge: pull request #53 from SOPT-all/feat/#49
Browse files Browse the repository at this point in the history
[FEAT/#49] 스프링 시큐리티를 통한 임시 인증 인가 처리 로직 추가
  • Loading branch information
ckkim817 authored Jan 22, 2025
2 parents c9618a4 + 8e1a4ae commit 1d987e0
Show file tree
Hide file tree
Showing 14 changed files with 443 additions and 67 deletions.
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ dependencies {
// PostGIS
implementation 'org.hibernate:hibernate-spatial:6.6.4.Final'
implementation 'org.locationtech.jts:jts-core:1.19.0' // TODO: 버전 확인하기

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

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.acon.server.global.auth;

import java.util.Collection;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

public class MemberAuthentication extends UsernamePasswordAuthenticationToken {

// 사용자 인증 객체 생성
public MemberAuthentication(
Object principal,
Object credentials,
Collection<? extends GrantedAuthority> authorities
) {
super(principal, credentials, authorities);
}
}
26 changes: 26 additions & 0 deletions src/main/java/com/acon/server/global/auth/PrincipalHandler.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.acon.server.global.auth;

import com.acon.server.global.exception.BusinessException;
import com.menugraphy.server.global.exception.ErrorType;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

@Component
public class PrincipalHandler {

private static final String ANONYMOUS_USER = "anonymousUser";

public Long getUserIdFromPrincipal() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
isPrincipalNull(principal);
return Long.valueOf(principal.toString());
}

public void isPrincipalNull(
final Object principal
) {
if (principal.toString().equals(ANONYMOUS_USER)) {
throw new BusinessException(ErrorType.EMPTY_PRINCIPAL_ERROR);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.acon.server.global.auth.filter;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
// 사용자가 인증은 되었지만 특정 리소스에 접근할 권한이 없을 때 호출

@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException
) {
setResponse(response);
}

private void setResponse(HttpServletResponse response) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.acon.server.global.auth.filter;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
// 사용자가 인증되지 않은 상태에서 보호된 리소스에 접근하려고 할 때 호출

@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) {
setResponse(response);
}

private void setResponse(HttpServletResponse response) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.acon.server.global.auth.filter;

import static com.acon.server.global.auth.jwt.JwtValidationType.VALID_JWT;

import com.acon.server.global.auth.MemberAuthentication;
import com.acon.server.global.auth.jwt.JwtTokenProvider;
import com.acon.server.global.exception.BusinessException;
import com.acon.server.global.exception.ErrorType;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

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

private final JwtTokenProvider jwtTokenProvider;

// 각 HTTP 요청에 대해 토큰이 유효한지 확인하고, 유효하다면 해당 사용자를 인증 설정하는 필터링 로직
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
try {
final String token = getJwtFromRequest(request);

if (jwtTokenProvider.validateToken(token) == VALID_JWT) {
Long memberId = jwtTokenProvider.getMemberIdFromJwt(token);

// authentication 객체 생성 -> principal에 유저정보를 담는다.
MemberAuthentication authentication = new MemberAuthentication(memberId.toString(), null, null);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception exception) {
// SecurityConfig에서 permitAll을 적용해도, Spring Security의 필터 체인을 거치므로
// 여기서 바로 Exception throw를 하게 되면 permitAll과 상관 없이 ExceptionTranslationFilter로 처리가 넘어간다.
// 따라서 예외를 직접 throw로 던져주는 것이 아닌, 발생시키기만 하고 다음 필터 호출로 이어지게끔 해야 하고, (doFilter)
// 이렇게 하면 API의 permitAll 적용 여부에 따라 ExceptionTranslationFilter를 거칠지 판단하게 된다.
log.error("JwtAuthentication Authentication Exception Occurs! - {}", exception.getMessage());
}
// 다음 필터로 요청 전달 (호출)
filterChain.doFilter(request, response);
}

// Authorization 헤더에서 JWT 토큰을 추출
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");

if (!StringUtils.hasText(bearerToken)) {
throw new BusinessException(ErrorType.UN_LOGIN_ERROR);
} else if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring("Bearer ".length());
} else if (StringUtils.hasText(bearerToken) && !bearerToken.startsWith("Bearer ")) {
throw new BusinessException(ErrorType.BEARER_LOST_ERROR);
}

return null;
}
}
115 changes: 115 additions & 0 deletions src/main/java/com/acon/server/global/auth/jwt/JwtTokenProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.acon.server.global.auth.jwt;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import javax.crypto.SecretKey;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

private static final String MEMBER_ID = "memberId";

@Value("${jwt.access-token-expire-time}")
private long ACCESS_TOKEN_EXPIRATION_TIME;

@Value("${jwt.refresh-token-expire-time}")
private long REFRESH_TOKEN_EXPIRATION_TIME;

@Value("${jwt.secret}")
private String JWT_SECRET;

@PostConstruct
protected void init() {
//base64 라이브러리에서 encodeToString을 이용해서 byte[] 형식을 String 형식으로 변환
JWT_SECRET = Base64.getEncoder().encodeToString(JWT_SECRET.getBytes(StandardCharsets.UTF_8));
}

public List<String> issueToken(Long memberId) {
return List.of(issueAccessToken(memberId), issueRefreshToken(memberId));
}

public String issueAccessToken(final Authentication authentication) {
return generateToken(authentication, ACCESS_TOKEN_EXPIRATION_TIME);
}

public String issueRefreshToken() {
final Date now = new Date();

return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION_TIME))
.signWith(getSigningKey())
.compact();
}

public String generateToken(
Authentication authentication,
Long tokenExpirationTime
) {
final Date now = new Date();

final Claims claims = Jwts.claims()
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenExpirationTime)); // 만료 시간 설정

claims.put(MEMBER_ID, authentication.getPrincipal());

return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // Header
.setClaims(claims) // Claim
.signWith(getSigningKey()) // Signature
.compact();
}

private SecretKey getSigningKey() {
String encodedKey = Base64.getEncoder().encodeToString(JWT_SECRET.getBytes()); // SecretKey를 통해 서명 생성

// 일반적으로 HMAC (Hash-based Message Authentication Code) 알고리즘을 사용
return Keys.hmacShaKeyFor(encodedKey.getBytes());
}

public JwtValidationType validateToken(String token) {
try {
final Claims claims = getBody(token);
return JwtValidationType.VALID_JWT;
} catch (MalformedJwtException ex) {
return JwtValidationType.INVALID_JWT_TOKEN;
} catch (ExpiredJwtException ex) {
return JwtValidationType.EXPIRED_JWT_TOKEN;
} catch (UnsupportedJwtException ex) {
return JwtValidationType.UNSUPPORTED_JWT_TOKEN;
} catch (IllegalArgumentException ex) {
return JwtValidationType.EMPTY_JWT;
}
}

private Claims getBody(final String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}

public Long getMemberIdFromJwt(String token) {
Claims claims = getBody(token);

return Long.valueOf(claims.get(MEMBER_ID).toString());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.acon.server.global.auth.jwt;

public enum JwtValidationType {
VALID_JWT, // 유효한 JWT
INVALID_JWT_SIGNATURE, // 유효하지 않은 서명
INVALID_JWT_TOKEN, // 유효하지 않은 토큰
EXPIRED_JWT_TOKEN, // 만료된 토큰
UNSUPPORTED_JWT_TOKEN, // 지원하지 않는 형식의 토큰
EMPTY_JWT // 빈 JWT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.acon.server.global.auth.security;

import com.acon.server.global.auth.filter.CustomAccessDeniedHandler;
import com.acon.server.global.auth.filter.CustomJwtAuthenticationEntryPoint;
import com.acon.server.global.auth.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
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.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity // WebSecurity를 사용할 수 있게
public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint;
private final CustomAccessDeniedHandler customAccessDeniedHandler;

private static final String[] AUTH_WHITE_LIST = {
"/api/v1/**",
"/api/v1/auth/**"
};

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.sessionManagement(session -> {
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
})
.exceptionHandling(exception -> {
exception.authenticationEntryPoint(customJwtAuthenticationEntryPoint);
exception.accessDeniedHandler(customAccessDeniedHandler);
});

http.authorizeHttpRequests(auth -> {
auth.requestMatchers(AUTH_WHITE_LIST).permitAll();
auth.anyRequest().authenticated();
})
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
}
}
Loading

0 comments on commit 1d987e0

Please sign in to comment.