-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
[FEAT] 카카오 로그인 구현
- Loading branch information
Showing
20 changed files
with
664 additions
and
48 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
100 changes: 79 additions & 21 deletions
100
src/main/java/danpoong/soenter/base/config/SecurityConfig.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,37 +1,95 @@ | ||
package danpoong.soenter.base.config; | ||
|
||
import danpoong.soenter.base.jwt.CustomAccessDeniedHandler; | ||
import danpoong.soenter.base.jwt.CustomJwtAuthenticationEntryPoint; | ||
import danpoong.soenter.base.jwt.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.crypto.bcrypt.BCryptPasswordEncoder; | ||
import org.springframework.security.crypto.password.PasswordEncoder; | ||
import org.springframework.security.web.SecurityFilterChain; | ||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; | ||
import org.springframework.web.cors.CorsConfiguration; | ||
import org.springframework.web.cors.CorsConfigurationSource; | ||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; | ||
|
||
import java.util.List; | ||
|
||
@Configuration | ||
@EnableWebSecurity | ||
@RequiredArgsConstructor | ||
@EnableWebSecurity | ||
public class SecurityConfig { | ||
private final JwtAuthenticationFilter jwtAuthenticationFilter; | ||
private final CustomJwtAuthenticationEntryPoint customJwtAuthenticationEntryPoint; | ||
private final CustomAccessDeniedHandler customAccessDeniedHandler; | ||
|
||
// Swagger 관련 경로 추가 | ||
private static final String[] AUTH_WHITELIST = { | ||
"/static/**", | ||
"/favicon.ico", | ||
"/css/**", | ||
"/js/**", | ||
"/manifest.json", | ||
"/", | ||
"/health", | ||
"http://localhost:3000", | ||
"http://localhost:8080", | ||
"https://api.ssoenter.store", | ||
"/swagger-ui.html", | ||
"/swagger-ui/**", | ||
"/v3/api-docs/**", | ||
"/kakao/login", | ||
"/kakao/callback", | ||
"/kakao/callback/**", | ||
"/users/**", | ||
"/api/**" | ||
}; | ||
|
||
@Bean | ||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { | ||
http.csrf(AbstractHttpConfigurer::disable) | ||
.cors(cors -> cors.configurationSource(corsConfigurationSource())) | ||
.formLogin(AbstractHttpConfigurer::disable) | ||
.httpBasic(AbstractHttpConfigurer::disable) | ||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | ||
.exceptionHandling(e -> { | ||
e.authenticationEntryPoint(customJwtAuthenticationEntryPoint); | ||
e.accessDeniedHandler(customAccessDeniedHandler); | ||
}); | ||
|
||
// Swagger 경로를 허용 목록에 포함 | ||
http.authorizeHttpRequests(auth -> { | ||
auth.requestMatchers(AUTH_WHITELIST).permitAll(); | ||
auth.anyRequest().authenticated(); | ||
}) | ||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); | ||
|
||
return http.build(); | ||
} | ||
|
||
@Bean | ||
public PasswordEncoder passwordEncoder() { | ||
return new BCryptPasswordEncoder(); | ||
} | ||
|
||
@Bean | ||
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { | ||
httpSecurity | ||
.csrf(csrf -> csrf.disable()) // CSRF 비활성화 | ||
.authorizeHttpRequests(auth -> auth | ||
.anyRequest().permitAll() // Allow all requests without authentication | ||
// .authorizeHttpRequests(auth -> auth | ||
// .requestMatchers( | ||
// new AntPathRequestMatcher("/"), | ||
// new AntPathRequestMatcher("/api/v0/auth/**"), | ||
// new AntPathRequestMatcher("/swagger-ui.html"), | ||
// new AntPathRequestMatcher("/swagger-ui/**"), | ||
// new AntPathRequestMatcher("/v3/api-docs/**"), | ||
// new AntPathRequestMatcher("/api-docs/**"), | ||
// new AntPathRequestMatcher("/health"), | ||
// ).permitAll() // 이 경로들은 모두 인증 없이 접근 가능 | ||
// .anyRequest().authenticated() // 그 외의 모든 경로는 인증 필요 | ||
) | ||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); | ||
return httpSecurity.build(); | ||
public CorsConfigurationSource corsConfigurationSource() { | ||
CorsConfiguration configuration = new CorsConfiguration(); | ||
configuration.setAllowedOrigins(List.of( | ||
"http://localhost:3000", | ||
"http://localhost:8080", | ||
"https://api.ssoenter.store" | ||
)); | ||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); | ||
configuration.setAllowedHeaders(List.of("Authorization", "Cache-Control", "Content-Type")); | ||
configuration.setAllowCredentials(true); | ||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); | ||
source.registerCorsConfiguration("/**", configuration); | ||
return source; | ||
} | ||
} | ||
} |
This file was deleted.
Oops, something went wrong.
19 changes: 19 additions & 0 deletions
19
src/main/java/danpoong/soenter/base/jwt/CustomAccessDeniedHandler.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
package danpoong.soenter.base.jwt; | ||
|
||
import jakarta.servlet.ServletException; | ||
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; | ||
|
||
import java.io.IOException; | ||
|
||
@Component | ||
public class CustomAccessDeniedHandler implements AccessDeniedHandler { | ||
|
||
@Override | ||
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { | ||
response.setStatus(HttpServletResponse.SC_FORBIDDEN); | ||
} | ||
} |
17 changes: 17 additions & 0 deletions
17
src/main/java/danpoong/soenter/base/jwt/CustomJwtAuthenticationEntryPoint.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
package danpoong.soenter.base.jwt; | ||
|
||
import jakarta.servlet.http.HttpServletRequest; | ||
import jakarta.servlet.http.HttpServletResponse; | ||
import org.springframework.security.core.AuthenticationException; | ||
import org.springframework.security.web.AuthenticationEntryPoint; | ||
import org.springframework.stereotype.Component; | ||
|
||
@Component | ||
public class CustomJwtAuthenticationEntryPoint implements AuthenticationEntryPoint { | ||
@Override | ||
public void commence(HttpServletRequest request, | ||
HttpServletResponse response, | ||
AuthenticationException authException) { | ||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); | ||
} | ||
} |
58 changes: 58 additions & 0 deletions
58
src/main/java/danpoong/soenter/base/jwt/JwtAuthenticationFilter.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
package danpoong.soenter.base.jwt; | ||
|
||
import jakarta.servlet.FilterChain; | ||
import jakarta.servlet.ServletException; | ||
import jakarta.servlet.http.HttpServletRequest; | ||
import jakarta.servlet.http.HttpServletResponse; | ||
import lombok.NonNull; | ||
import lombok.RequiredArgsConstructor; | ||
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; | ||
|
||
import java.io.IOException; | ||
|
||
//모든 요청에 대해 실행되며 요청의 Authorization 헤더에서 jwt 추출, 검증해 사용자 인증 | ||
@Component | ||
@RequiredArgsConstructor | ||
public class JwtAuthenticationFilter extends OncePerRequestFilter { | ||
private final JwtTokenProvider jwtTokenProvider; //jwt 토큰 생성, 검증 | ||
|
||
@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) == JwtValidationType.VALID_JWT) { | ||
Long userId = jwtTokenProvider.getUserIdFromJwt(token); | ||
String kakaoAccessTokenFromJwt = jwtTokenProvider.getKakaoAccessTokenFromJwt(token); | ||
//userId를 principal(주체)로 설정 | ||
UserAuthentication authentication = new UserAuthentication(userId.toString(), null, null, kakaoAccessTokenFromJwt); | ||
//IP 주소, 세션 ID 같은 요청 관련 정보를 포함하는 객체를 생성해서 추가적인 인증 정보 설정 | ||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); | ||
//spring security의 인증 컨텍스트에 설정 | ||
//현재 스레드의 보안 컨텍스트에 authentication을 설정해 이후의 요청 처리 과정에서 현재 사용자가 인증된 사용자로 간주됨. | ||
SecurityContextHolder.getContext().setAuthentication(authentication); | ||
} | ||
} catch (Exception exception) { | ||
try { | ||
throw new Exception(); | ||
} catch (Exception e) { | ||
throw new RuntimeException(e); | ||
} | ||
} | ||
filterChain.doFilter(request, response); //다음 필터로 요청 전달 | ||
} | ||
|
||
//요청의 Authorization 헤더에서 Bearer 토큰 찾아서 반환 | ||
private String getJwtFromRequest(HttpServletRequest request) { | ||
String bearerToken = request.getHeader("Authorization"); | ||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { | ||
return bearerToken.substring("Bearer ".length()); //Bearer 이후의 문자열을 잘라내서 반환, 실제 jwt | ||
} | ||
return null; | ||
} | ||
} |
94 changes: 94 additions & 0 deletions
94
src/main/java/danpoong/soenter/base/jwt/JwtTokenProvider.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
package danpoong.soenter.base.jwt; | ||
|
||
import io.jsonwebtoken.*; | ||
import io.jsonwebtoken.security.Keys; | ||
import jakarta.annotation.PostConstruct; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.security.core.Authentication; | ||
import org.springframework.stereotype.Component; | ||
|
||
import javax.crypto.SecretKey; | ||
import java.nio.charset.StandardCharsets; | ||
import java.util.Base64; | ||
import java.util.Date; | ||
|
||
@Component | ||
@RequiredArgsConstructor | ||
public class JwtTokenProvider { | ||
|
||
private static final String USER_ID = "userId"; | ||
private static final String KAKAO_ACCESS_TOKEN = "kakaoAccessToken"; | ||
private static final Long TOKEN_EXPIRATION_TIME = 60 * 60 * 1000L; //1시간 | ||
|
||
@Value("${jwt.secret}") | ||
private String JWT_SECRET; //jwt 서명을 위한 비밀키 | ||
|
||
@PostConstruct | ||
protected void init() { | ||
//UTF-8 인코딩 된 바이트 배열 -> Base64 인코딩 된 문자열 | ||
//서명 및 검증 과정에서 비밀 키를 안전하게 관리하기 위해 수행 | ||
JWT_SECRET = Base64.getEncoder() | ||
.encodeToString(JWT_SECRET.getBytes(StandardCharsets.UTF_8)); | ||
} | ||
|
||
//토큰 생성 | ||
public String generateToken(Authentication authentication) { | ||
final Date now = new Date(); | ||
final Claims claims = Jwts.claims() | ||
.setIssuedAt(now) | ||
.setExpiration(new Date(now.getTime() + TOKEN_EXPIRATION_TIME)); | ||
|
||
claims.put(USER_ID, authentication.getPrincipal()); | ||
if (authentication instanceof UserAuthentication) { | ||
claims.put(KAKAO_ACCESS_TOKEN, ((UserAuthentication) authentication).getKakaoAccessToken()); | ||
} | ||
|
||
return Jwts.builder() | ||
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) | ||
.setClaims(claims) | ||
.signWith(getSigningKey()) | ||
.compact(); | ||
} | ||
|
||
//서명에 사용할 키 생성 | ||
private SecretKey getSigningKey() { | ||
String encodedKey = Base64.getEncoder().encodeToString(JWT_SECRET.getBytes()); | ||
return Keys.hmacShaKeyFor(encodedKey.getBytes()); //인코딩된 키를 HMAC-SHA 키로 변환 | ||
} | ||
|
||
public Long getUserIdFromJwt(String token) { | ||
Claims claims = getBody(token); | ||
return Long.valueOf(claims.get(USER_ID).toString()); //userId를 Long으로 변환해 반환 | ||
} | ||
|
||
public String getKakaoAccessTokenFromJwt(String token) { | ||
Claims claims = getBody(token); | ||
return claims.get(KAKAO_ACCESS_TOKEN).toString(); | ||
} | ||
|
||
//토큰 유효성 검사 | ||
public JwtValidationType validateToken(String token) { | ||
try { | ||
final Claims claims = getBody(token); //클레임에서 바디 추출 | ||
return JwtValidationType.VALID_JWT; | ||
} catch (MalformedJwtException e) { | ||
return JwtValidationType.INVALID_JWT_TOKEN; | ||
} catch (ExpiredJwtException e) { | ||
return JwtValidationType.EXPIRED_JWT_TOKEN; | ||
} catch (UnsupportedJwtException e) { | ||
return JwtValidationType.UNSUPPORTED_JWT_TOKEN; | ||
} catch (IllegalArgumentException e) { | ||
return JwtValidationType.EMPTY_JWT; | ||
} | ||
} | ||
|
||
//jwt 토큰에서 클레임 추출 | ||
private Claims getBody(final String token) { | ||
return Jwts.parserBuilder() //jwt 문자열을 파싱하기 위한 빌더 생성 | ||
.setSigningKey(getSigningKey()) //서명 키 설정 | ||
.build() | ||
.parseClaimsJws(token) //토큰 파싱, 서명 검증 | ||
.getBody(); //클레임 반환 | ||
} | ||
} |
10 changes: 10 additions & 0 deletions
10
src/main/java/danpoong/soenter/base/jwt/JwtValidationType.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package danpoong.soenter.base.jwt; | ||
|
||
public enum JwtValidationType { | ||
VALID_JWT, //유효한 jwt | ||
INVALID_JWT_SIGNATURE, //유효하지 않은 서명 | ||
INVALID_JWT_TOKEN, //유효하지 않은 토큰 | ||
EXPIRED_JWT_TOKEN, //만료된 토큰 | ||
UNSUPPORTED_JWT_TOKEN, //지원하지 않는 형식의 토큰 | ||
EMPTY_JWT //빈 jwt | ||
} |
20 changes: 20 additions & 0 deletions
20
src/main/java/danpoong/soenter/base/jwt/UserAuthentication.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
package danpoong.soenter.base.jwt; | ||
|
||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | ||
import org.springframework.security.core.GrantedAuthority; | ||
|
||
import java.util.Collection; | ||
|
||
//사용자 인증 객체를 나타내고, 인증된 사용자에 대한 정보 포함. 주로 사용자 인증 및 권한 부여에 사용 | ||
public class UserAuthentication extends UsernamePasswordAuthenticationToken { | ||
private final String kakaoAccessToken; | ||
|
||
public UserAuthentication(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities, String kakaoAccessToken) { | ||
super(principal, credentials, authorities); | ||
this.kakaoAccessToken = kakaoAccessToken; | ||
} | ||
|
||
public String getKakaoAccessToken() { | ||
return kakaoAccessToken; | ||
} | ||
} |
Oops, something went wrong.