Skip to content

Commit

Permalink
Merge pull request #4 from 9oormthon-univ/feature/#3
Browse files Browse the repository at this point in the history
[FEAT] 카카오 로그인 구현
  • Loading branch information
haeun1107 authored Nov 18, 2024
2 parents f7cc896 + bb2c39f commit 1a8f3e7
Show file tree
Hide file tree
Showing 20 changed files with 664 additions and 48 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ jobs:
DB_URL: ${{ secrets.DB_URL }}
DB_USERNAME: ${{ secrets.DB_USERNAME }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
KAKAO_CLIENT_ID: ${{ secrets.KAKAO_CLIENT_ID }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -66,6 +68,8 @@ jobs:
echo "DB_URL=${{ secrets.DB_URL }}" > ~/.env
echo "DB_USERNAME=${{ secrets.DB_USERNAME }}" >> ~/.env
echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> ~/.env
echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> ~/.env
echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> ~/.env
# 새 컨테이너 실행 및 환경 변수 전달
sudo docker run -d --log-driver=syslog --name docker-test -p 8080:8080 \
Expand Down
100 changes: 79 additions & 21 deletions src/main/java/danpoong/soenter/base/config/SecurityConfig.java
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;
}
}
}
26 changes: 0 additions & 26 deletions src/main/java/danpoong/soenter/base/config/WebConfig.java

This file was deleted.

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);
}
}
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);
}
}
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 src/main/java/danpoong/soenter/base/jwt/JwtTokenProvider.java
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 src/main/java/danpoong/soenter/base/jwt/JwtValidationType.java
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 src/main/java/danpoong/soenter/base/jwt/UserAuthentication.java
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;
}
}
Loading

0 comments on commit 1a8f3e7

Please sign in to comment.