Skip to content

Commit

Permalink
Feat: OAuth 2.0를 활용한 로그인 구현 (#24)
Browse files Browse the repository at this point in the history
* * #11 - feat: oauth, jwt, security test 의존성 삽입

* * #11 - feat: oauth, jwt 관련 설정 추가

OAuth provider 는 카카오와 네이버

* * #11 - feat: Member Entity에 email, name, oauth 관련 컬럼 추가

* * #11 - feat: Oauth2.0을 이용한 로그인 구현

Role 추가하지 않았고 세션 방식 없앴음

* * #11 - feat: OAuth 인증 후 JWT 발급 구현

refresh token, black list 추후 구현

* * #11 - feat: 기존 테스트 인증에도 잘 돌아가도록 수정

* * #11 - refactor: DTO 를 record로 변경

* * #11 - feat: 토큰 유효기간 변경

* * #11 - feat: 인증 중 유저 정보에 트랜잭션 처리

* * #11 - feat: 유저를 찾을 때 이메일이 아닌 OAuthID와 Provider로 유니크하게 찾게 변경

* * #11 - refactor: 불필요한 기본 생성자 삭제
  • Loading branch information
morenow98 authored Jan 15, 2024
1 parent d4e6aff commit 7c80e9d
Show file tree
Hide file tree
Showing 21 changed files with 674 additions and 3 deletions.
7 changes: 7 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

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

// p6spy
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
Expand All @@ -41,6 +47,7 @@ dependencies {
testRuntimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('bootBuildImage') {
Expand Down
8 changes: 5 additions & 3 deletions src/main/java/cotato/bookitlist/BookitlistApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;

@ConfigurationPropertiesScan
@SpringBootApplication
public class BookitlistApplication {

public static void main(String[] args) {
SpringApplication.run(BookitlistApplication.class, args);
}
public static void main(String[] args) {
SpringApplication.run(BookitlistApplication.class, args);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package cotato.bookitlist.config.security;

import cotato.bookitlist.config.security.jwt.JwtAuthenticationFilter;
import cotato.bookitlist.config.security.jwt.JwtTokenProvider;
import cotato.bookitlist.config.security.oauth.service.CustomOAuth2UserService;
import cotato.bookitlist.config.security.oauth.handler.OAuth2AuthenticationSuccessHandler;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
private final JwtTokenProvider jwtTokenProvider;
private final String[] WHITE_LIST = {
"/health_check",
"/swagger-ui/**",
"/h2-console/**"
};

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable).disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(WHITE_LIST).permitAll()
.requestMatchers(HttpMethod.GET).permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.userInfoEndpoint((userInfo) -> userInfo.userService(this.customOAuth2UserService))
.successHandler(this.oAuth2AuthenticationSuccessHandler))
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)

.exceptionHandling(exception ->
exception.authenticationEntryPoint((request, response, authException) ->
response.sendError(HttpServletResponse.SC_FORBIDDEN)));
return http.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package cotato.bookitlist.config.security.jwt;

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

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

@AllArgsConstructor
@Getter
public class AuthDetails implements UserDetails {

private String userId;

private String role;

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singleton(new SimpleGrantedAuthority("ROLE_" + role));
}

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

@Override
public String getUsername() {
return userId;
}

@Override
public boolean isAccountNonExpired() {
return true;
}

@Override
public boolean isAccountNonLocked() {
return true;
}

@Override
public boolean isCredentialsNonExpired() {
return true;
}

@Override
public boolean isEnabled() {
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package cotato.bookitlist.config.security.jwt;

import cotato.bookitlist.config.security.jwt.dto.AccessTokenInfo;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;

import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.WebUtils;

@RequiredArgsConstructor
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

private final JwtTokenProvider jwtTokenProvider;

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = this.resolveToken(request);
if (token != null) {
Authentication authentication = this.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}

filterChain.doFilter(request, response);
}

private String resolveToken(HttpServletRequest request) {
Cookie accessTokenCookie = WebUtils.getCookie(request, "accessToken");
if (accessTokenCookie != null) {
return accessTokenCookie.getValue();
} else {
String rawHeader = request.getHeader("Authorization");
String bearer = "Bearer ";
return rawHeader != null && rawHeader.length() > bearer.length() && rawHeader.startsWith(bearer) ? rawHeader.substring(bearer.length()) : null;
}
}

public Authentication getAuthentication(String token) {
AccessTokenInfo accessTokenInfo = this.jwtTokenProvider.parseAccessToken(token);
UserDetails userDetails = new AuthDetails(accessTokenInfo.userId().toString(), accessTokenInfo.role());
return new UsernamePasswordAuthenticationToken(userDetails, "user", userDetails.getAuthorities());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package cotato.bookitlist.config.security.jwt;

import cotato.bookitlist.config.security.jwt.dto.AccessTokenInfo;
import cotato.bookitlist.config.security.jwt.properties.JwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

private final JwtProperties jwtProperties;

private Jws<Claims> getJws(String token) {
try {
return Jwts.parserBuilder().setSigningKey(getSecretKey()).build().parseClaimsJws(token);
} catch (ExpiredJwtException ex) {
throw new RuntimeException();
} catch (Exception ex) {
throw new RuntimeException();
}
}

private Key getSecretKey() {
return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes(StandardCharsets.UTF_8));
}

private String buildAccessToken(Long id, Date issuedAt, Date accessTokenExpiresIn, String role) {
final Key encodedKey = getSecretKey();
return Jwts.builder()
.setIssuer("bookitlist")
.setIssuedAt(issuedAt)
.setSubject(id.toString())
.claim("type", "ACCESS_TOKEN")
.claim("role", role)
.setExpiration(accessTokenExpiresIn)
.signWith(encodedKey)
.compact();
}

private String buildRefreshToken(Long id, Date issuedAt, Date accessTokenExpiresIn) {
Key encodedKey = getSecretKey();
return Jwts.builder()
.setIssuer("bookitlist")
.setIssuedAt(issuedAt)
.setSubject(id.toString())
.claim("type", "REFRESH_TOKEN")
.setExpiration(accessTokenExpiresIn)
.signWith(encodedKey)
.compact();
}

public String generateAccessToken(Long id, String role) {
Date issuedAt = new Date();
Date accessTokenExpiresIn = new Date(issuedAt.getTime() + getAccessTokenTTlSecond() * 1000);

return buildAccessToken(id, issuedAt, accessTokenExpiresIn, role);
}

public String generateRefreshToken(Long id) {
Date issuedAt = new Date();
Date refreshTokenExpiresIn = new Date(issuedAt.getTime() + getRefreshTokenTTlSecond() * 1000);

return buildRefreshToken(id, issuedAt, refreshTokenExpiresIn);
}

public boolean isAccessToken(String token) {
return getJws(token).getBody().get("type").equals("ACCESS_TOKEN");
}

public boolean isRefreshToken(String token) {
return getJws(token).getBody().get("type").equals("REFRESH_TOKEN");
}

public AccessTokenInfo parseAccessToken(String token) {
if (isAccessToken(token)) {
Claims claims = getJws(token).getBody();
return AccessTokenInfo.of(
Long.parseLong(claims.getSubject()),
(String) claims.get("role")
);
} else {
throw new RuntimeException();
}
}

public Long parseRefreshToken(String token) {
try {
if (isRefreshToken(token)) {
Claims claims = getJws(token).getBody();
return Long.parseLong(claims.getSubject());
}
} catch (ExpiredJwtException ex) {
throw new RuntimeException();
}

throw new RuntimeException();
}

public Long getRefreshTokenTTlSecond() {
return jwtProperties.getRefreshExp();
}

public Long getAccessTokenTTlSecond() {
return jwtProperties.getAccessExp();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package cotato.bookitlist.config.security.jwt.dto;

public record AccessTokenInfo(
Long userId,
String role
) {
public static AccessTokenInfo of(Long userId, String role) {
return new AccessTokenInfo(userId, role);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package cotato.bookitlist.config.security.jwt.properties;

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Getter
@AllArgsConstructor
@ConfigurationProperties(prefix = "auth.jwt")
public class JwtProperties {
private String secretKey;
private Long accessExp;
private Long refreshExp;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package cotato.bookitlist.config.security.oauth;

public enum AuthProvider {
KAKAO,
NAVER,

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cotato.bookitlist.config.security.oauth;

import java.util.Map;

public class KakaoOAuth2User extends OAuth2UserInfo {
private final Long id;

public KakaoOAuth2User(Map<String, Object> attributes) {
super((Map) attributes.get("kakao_account"));
this.id = (Long) attributes.get("id");
}

@Override
public String getOAuth2Id() {
return this.id.toString();
}

@Override
public String getEmail() {
return (String) this.attributes.get("email");
}

@Override
public String getName() {
return (String) ((Map) this.attributes.get("profile")).get("nickname");
}
}
Loading

0 comments on commit 7c80e9d

Please sign in to comment.