diff --git a/build.gradle b/build.gradle index a2cc942..8a4032b 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' @@ -32,6 +33,22 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // jwt + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' + + // Apple 로그인 연동시 필요한 설정 + implementation group: 'com.nimbusds', name: 'nimbus-jose-jwt', version: '9.30.1' + implementation group: 'org.bouncycastle', name: 'bcpkix-jdk15on', version: '1.70' + implementation 'org.apache.directory.studio:org.apache.commons.io:2.4' } tasks.named('test') { diff --git a/src/main/java/dorun/project/routineapp/RoutineAppApplication.java b/src/main/java/dorun/project/routineapp/RoutineAppApplication.java index 28f7df5..a179a38 100644 --- a/src/main/java/dorun/project/routineapp/RoutineAppApplication.java +++ b/src/main/java/dorun/project/routineapp/RoutineAppApplication.java @@ -1,9 +1,16 @@ package dorun.project.routineapp; +import dorun.project.routineapp.config.properties.AppProperties; +import dorun.project.routineapp.config.properties.CorsProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; @SpringBootApplication +@EnableConfigurationProperties({ + CorsProperties.class, + AppProperties.class +}) public class RoutineAppApplication { public static void main(String[] args) { diff --git a/src/main/java/dorun/project/routineapp/api/controller/AuthController.java b/src/main/java/dorun/project/routineapp/api/controller/AuthController.java new file mode 100644 index 0000000..e980dd8 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/api/controller/AuthController.java @@ -0,0 +1,92 @@ +package dorun.project.routineapp.api.controller; + +import dorun.project.routineapp.api.entity.RefreshToken; +import dorun.project.routineapp.common.APIResponse; +import dorun.project.routineapp.config.properties.AppProperties; +import dorun.project.routineapp.oauth.entity.RoleType; +import dorun.project.routineapp.oauth.token.Token; +import dorun.project.routineapp.oauth.token.TokenProvider; +import dorun.project.routineapp.oauth.util.HeaderUtil; +import dorun.project.routineapp.api.repository.RefreshTokenRepository; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + private final AppProperties appProperties; + private final TokenProvider tokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + private static final long UPDATE_TOKEN_STRATEGY = 259200000; // 3일 + private static final String ACCESS_TOKEN = "access_token"; + private static final String REFRESH_TOKEN = "refresh_token"; + + @PostMapping("/refresh") + public APIResponse> refreshToken(HttpServletRequest request, HttpServletResponse response) { + String accessToken = HeaderUtil.getToken(request, ACCESS_TOKEN); + Token authToken = tokenProvider.convertToken(accessToken); + if (!authToken.validate()) { + return APIResponse.invalidRefreshToken(); + } + + Claims claims = authToken.getExpiredTokenClaims(); + if (claims == null) { + return APIResponse.notExpiredTokenYet(); + } + + String userId = claims.getSubject(); + RoleType roleType = RoleType.of(claims.get("role", String.class)); + + String refreshToken = HeaderUtil.getToken(request, REFRESH_TOKEN); + Token authRefreshToken = tokenProvider.convertToken(refreshToken); + + if (authRefreshToken.validate()) { + return APIResponse.invalidRefreshToken(); + } + + Optional searchedRefreshToken = refreshTokenRepository.findByUserId(userId); + RefreshToken userRefreshToken; + if (searchedRefreshToken.isEmpty()) { + return APIResponse.invalidRefreshToken(); + } + userRefreshToken = searchedRefreshToken.get(); + + Date now = new Date(); + Token newAccessToken = tokenProvider.createToken( + userId, + roleType.getCode(), + new Date(now.getTime() + appProperties.getAuth().getTokenExpiry()) + ); + + long validTime = authRefreshToken.getTokenClaims().getExpiration().getTime() - now.getTime(); + + if (validTime <= UPDATE_TOKEN_STRATEGY) { + long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry(); + + authRefreshToken = tokenProvider.createToken( + appProperties.getAuth().getTokenSecret(), + new Date(now.getTime() + refreshTokenExpiry) + ); + + userRefreshToken.updateRefreshToken(authRefreshToken.getToken()); + refreshTokenRepository.save(userRefreshToken); + } + + Map tokens = new HashMap<>(); + tokens.put(ACCESS_TOKEN, newAccessToken.getToken()); + tokens.put(REFRESH_TOKEN, userRefreshToken.getRefreshToken()); + + return APIResponse.success("tokens", tokens); + } +} diff --git a/src/main/java/dorun/project/routineapp/api/entity/BaseEntity.java b/src/main/java/dorun/project/routineapp/api/entity/BaseEntity.java new file mode 100644 index 0000000..e3fd7a8 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/api/entity/BaseEntity.java @@ -0,0 +1,24 @@ +package dorun.project.routineapp.api.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@MappedSuperclass +@Getter +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @Column(name = "CREATED_AT") + @CreatedDate + private LocalDateTime createdAt; + + @Column(name = "MODIFIED_AT") + @LastModifiedDate + private LocalDateTime modifiedAt; +} diff --git a/src/main/java/dorun/project/routineapp/api/entity/RefreshToken.java b/src/main/java/dorun/project/routineapp/api/entity/RefreshToken.java new file mode 100644 index 0000000..e10c308 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/api/entity/RefreshToken.java @@ -0,0 +1,22 @@ +package dorun.project.routineapp.api.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@AllArgsConstructor +@RedisHash(value = "refreshToken", timeToLive = 60 * 60 * 24 * 3) +public class RefreshToken { + @Id + private String UUID; + @Indexed + private String userId; + private String refreshToken; + + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/dorun/project/routineapp/api/entity/User.java b/src/main/java/dorun/project/routineapp/api/entity/User.java new file mode 100644 index 0000000..aff8677 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/api/entity/User.java @@ -0,0 +1,81 @@ +package dorun.project.routineapp.api.entity; + +import dorun.project.routineapp.oauth.entity.ProviderType; +import dorun.project.routineapp.oauth.entity.RoleType; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "USER") +public class User extends BaseEntity { + @JsonIgnore + @Id + @Column(name = "USER_SEQ") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userSeq; + + @Column(name = "USER_ID", length = 64, unique = true) + @NotNull + @Size(max = 64) + private String userId; + + @Column(name = "USERNAME", length = 100) + @NotNull + @Size(max = 100) + private String username; + + @JsonIgnore + @Column(name = "PASSWORD", length = 128) + @NotNull + @Size(max = 128) + private String password; + + @Column(name = "EMAIL", length = 512, unique = true) + @NotNull + @Size(max = 512) + private String email; + + @Column(name = "PROVIDER_TYPE", length = 20) + @Enumerated(EnumType.STRING) + @NotNull + private ProviderType providerType; + + @Column(name = "ROLE_TYPE", length = 20) + @Enumerated(EnumType.STRING) + @NotNull + private RoleType roleType; + + public User( + @NotNull @Size(max = 64) String userId, + @NotNull @Size(max = 100) String username, + @NotNull @Size(max = 512) String email, + @NotNull ProviderType providerType, + @NotNull RoleType roleType + ) { + this.userId = userId; + this.username = username; + this.password = "NO_PASS"; + this.email = email != null ? email : "NO_EMAIL"; + this.providerType = providerType; + this.roleType = roleType; + } +} \ No newline at end of file diff --git a/src/main/java/dorun/project/routineapp/api/repository/RefreshTokenRepository.java b/src/main/java/dorun/project/routineapp/api/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..5681a67 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/api/repository/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package dorun.project.routineapp.api.repository; + +import dorun.project.routineapp.api.entity.RefreshToken; +import java.util.Optional; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RefreshTokenRepository extends CrudRepository { + Optional findByUserId(String userId); +} diff --git a/src/main/java/dorun/project/routineapp/api/repository/UserRepository.java b/src/main/java/dorun/project/routineapp/api/repository/UserRepository.java new file mode 100644 index 0000000..fc46127 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/api/repository/UserRepository.java @@ -0,0 +1,10 @@ +package dorun.project.routineapp.api.repository; + +import dorun.project.routineapp.api.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { + User findByUserId(String userId); +} diff --git a/src/main/java/dorun/project/routineapp/common/APIResponse.java b/src/main/java/dorun/project/routineapp/common/APIResponse.java new file mode 100644 index 0000000..9376f09 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/common/APIResponse.java @@ -0,0 +1,47 @@ +package dorun.project.routineapp.common; + +import java.util.HashMap; +import java.util.Map; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class APIResponse { + + private final static int SUCCESS = 200; + private final static int NOT_FOUND = 400; + private final static int FAILED = 500; + private final static String SUCCESS_MESSAGE = "SUCCESS"; + private final static String NOT_FOUND_MESSAGE = "NOT FOUND"; + private final static String FAILED_MESSAGE = "서버에서 오류가 발생하였습니다."; + private final static String INVALID_ACCESS_TOKEN = "Access Token이 유효하지 않습니다."; + private final static String INVALID_REFRESH_TOKEN = "Refresh Token이 유효하지 않습니다."; + private final static String NOT_EXPIRED_TOKEN_YET = "Token이 아직 만료되지 않았습니다."; + + private final APIResponseHeader header; + private final Map body; + + public static APIResponse success(String name, T body) { + Map map = new HashMap<>(); + map.put(name, body); + + return new APIResponse<>(new APIResponseHeader(SUCCESS, SUCCESS_MESSAGE), map); + } + + public static APIResponse fail() { + return new APIResponse<>(new APIResponseHeader(FAILED, FAILED_MESSAGE), null); + } + + public static APIResponse invalidAccessToken() { + return new APIResponse<>(new APIResponseHeader(FAILED, INVALID_ACCESS_TOKEN), null); + } + + public static APIResponse invalidRefreshToken() { + return new APIResponse<>(new APIResponseHeader(FAILED, INVALID_REFRESH_TOKEN), null); + } + + public static APIResponse notExpiredTokenYet() { + return new APIResponse<>(new APIResponseHeader(FAILED, NOT_EXPIRED_TOKEN_YET), null); + } +} diff --git a/src/main/java/dorun/project/routineapp/common/APIResponseHeader.java b/src/main/java/dorun/project/routineapp/common/APIResponseHeader.java new file mode 100644 index 0000000..7ab0899 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/common/APIResponseHeader.java @@ -0,0 +1,13 @@ +package dorun.project.routineapp.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class APIResponseHeader { + private int code; + private String message; +} diff --git a/src/main/java/dorun/project/routineapp/config/JwtConfig.java b/src/main/java/dorun/project/routineapp/config/JwtConfig.java new file mode 100644 index 0000000..ff79907 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/config/JwtConfig.java @@ -0,0 +1,17 @@ +package dorun.project.routineapp.config; + +import dorun.project.routineapp.oauth.token.TokenProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JwtConfig { + @Value("${jwt.secret}") + private String secret; + + @Bean + public TokenProvider jwtProvider() { + return new TokenProvider(secret); + } +} diff --git a/src/main/java/dorun/project/routineapp/config/OpenApiConfig.java b/src/main/java/dorun/project/routineapp/config/OpenApiConfig.java new file mode 100644 index 0000000..1d2a92a --- /dev/null +++ b/src/main/java/dorun/project/routineapp/config/OpenApiConfig.java @@ -0,0 +1,21 @@ +package dorun.project.routineapp.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + @Bean + public OpenAPI openAPI() { + Info info = new Info() + .title("소셜 로그인 템플릿 프로젝트 API Document") + .version("v0.0.1") + .description("소셜 로그인 템플릿 프로젝트의 API 명세서입니다."); + return new OpenAPI() + .components(new Components()) + .info(info); + } +} \ No newline at end of file diff --git a/src/main/java/dorun/project/routineapp/config/RedisConfig.java b/src/main/java/dorun/project/routineapp/config/RedisConfig.java new file mode 100644 index 0000000..c0cbaaf --- /dev/null +++ b/src/main/java/dorun/project/routineapp/config/RedisConfig.java @@ -0,0 +1,27 @@ +package dorun.project.routineapp.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + @Value("${spring.data.redis.host}") + private String host; + @Value("${spring.data.redis.port}") + private int port; + @Value("${spring.data.redis.password") + private String password; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(host, port); + redisStandaloneConfiguration.setPassword(password); + return new LettuceConnectionFactory(redisStandaloneConfiguration); + } +} diff --git a/src/main/java/dorun/project/routineapp/config/SecurityConfig.java b/src/main/java/dorun/project/routineapp/config/SecurityConfig.java new file mode 100644 index 0000000..868fd96 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/config/SecurityConfig.java @@ -0,0 +1,130 @@ +package dorun.project.routineapp.config; + +import dorun.project.routineapp.api.repository.RefreshTokenRepository; +import dorun.project.routineapp.config.properties.AppProperties; +import dorun.project.routineapp.config.properties.CorsProperties; +import dorun.project.routineapp.oauth.endpoint.CustomRequestEntityConverter; +import dorun.project.routineapp.oauth.entity.RoleType; +import dorun.project.routineapp.oauth.exception.RestAuthenticationEntryPoint; +import dorun.project.routineapp.oauth.filter.TokenAuthenticationFilter; +import dorun.project.routineapp.oauth.handler.OAuth2AuthenticationFailureHandler; +import dorun.project.routineapp.oauth.handler.OAuth2AuthenticationSuccessHandler; +import dorun.project.routineapp.oauth.handler.TokenAccessDeniedHandler; +import dorun.project.routineapp.oauth.service.CustomOAuth2UserService; +import dorun.project.routineapp.oauth.token.TokenProvider; +import java.util.Arrays; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsUtils; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + private final CorsProperties corsProperties; + private final AppProperties appProperties; + private final TokenProvider tokenProvider; +// private final CustomUserDetailsService userDetailsService; + private final CustomOAuth2UserService oAuth2UserService; + private final TokenAccessDeniedHandler tokenAccessDeniedHandler; + private final RefreshTokenRepository refreshTokenRepository; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(httpSecurityCorsConfigurer -> + httpSecurityCorsConfigurer.configurationSource(corsConfigurationSource())) + .sessionManagement(httpSecuritySessionManagementConfigurer -> + httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(new RestAuthenticationEntryPoint()) + .accessDeniedHandler(tokenAccessDeniedHandler) + ) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() + .requestMatchers("/","/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/api/**").hasAnyAuthority(RoleType.USER.getCode()) + .requestMatchers("/api/**/admin/**").hasAnyAuthority(RoleType.ADMIN.getCode()) + .anyRequest().authenticated() + ) + .oauth2Login(oauth2 -> oauth2 + .tokenEndpoint(endpoint -> endpoint + .accessTokenResponseClient(accessTokenResponseClient())) + .authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint + .baseUri("/oauth2/authorization")) + .redirectionEndpoint(redirectionEndpoint -> redirectionEndpoint + .baseUri("/*/oauth2/code/*")) + .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint + .userService(oAuth2UserService)) + + .successHandler(oAuth2AuthenticationSuccessHandler()) + .failureHandler(oAuth2AuthenticationFailureHandler()) + ); + + http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public TokenAuthenticationFilter tokenAuthenticationFilter() { + return new TokenAuthenticationFilter(tokenProvider); + } + + @Bean + public OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler() { + return new OAuth2AuthenticationSuccessHandler( + tokenProvider, + appProperties, + refreshTokenRepository + ); + } + + @Bean + public OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler() { + return new OAuth2AuthenticationFailureHandler(); + } + + @Bean + public UrlBasedCorsConfigurationSource corsConfigurationSource() { + UrlBasedCorsConfigurationSource corsConfigSource = new UrlBasedCorsConfigurationSource(); + + CorsConfiguration corsConfig = new CorsConfiguration(); + corsConfig.setAllowedHeaders(Arrays.asList(corsProperties.getAllowedHeaders().split(","))); + corsConfig.setAllowedMethods(Arrays.asList(corsProperties.getAllowedMethods().split(","))); + corsConfig.setAllowedOrigins(Arrays.asList(corsProperties.getAllowedOrigins().split(","))); + corsConfig.setAllowCredentials(true); + corsConfig.setMaxAge(corsConfig.getMaxAge()); + + corsConfigSource.registerCorsConfiguration("/**", corsConfig); + return corsConfigSource; + } + + @Bean + public OAuth2AccessTokenResponseClient accessTokenResponseClient(){ + DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient(); + accessTokenResponseClient.setRequestEntityConverter(new CustomRequestEntityConverter()); + + return accessTokenResponseClient; + } +} diff --git a/src/main/java/dorun/project/routineapp/config/properties/AppProperties.java b/src/main/java/dorun/project/routineapp/config/properties/AppProperties.java new file mode 100644 index 0000000..f9a2ae7 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/config/properties/AppProperties.java @@ -0,0 +1,39 @@ +package dorun.project.routineapp.config.properties; + +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@ConfigurationProperties(prefix = "app") +public class AppProperties { + private final Auth auth = new Auth(); + private final OAuth2 oauth2 = new OAuth2(); + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class Auth { + private String tokenSecret; + private long tokenExpiry; + private long refreshTokenExpiry; + } + + public static final class OAuth2 { + private List authorizedRedirectUris = new ArrayList<>(); + + public List getAuthorizedRedirectUris() { + return authorizedRedirectUris; + } + + public OAuth2 authorizedRedirectUris(List authorizedRedirectUris) { + this.authorizedRedirectUris = authorizedRedirectUris; + return this; + } + } +} diff --git a/src/main/java/dorun/project/routineapp/config/properties/CorsProperties.java b/src/main/java/dorun/project/routineapp/config/properties/CorsProperties.java new file mode 100644 index 0000000..a86574a --- /dev/null +++ b/src/main/java/dorun/project/routineapp/config/properties/CorsProperties.java @@ -0,0 +1,15 @@ +package dorun.project.routineapp.config.properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "cors") +public class CorsProperties { + private String allowedOrigins; + private String allowedMethods; + private String allowedHeaders; + private Long maxAge; +} diff --git a/src/main/java/dorun/project/routineapp/oauth/endpoint/CustomRequestEntityConverter.java b/src/main/java/dorun/project/routineapp/oauth/endpoint/CustomRequestEntityConverter.java new file mode 100644 index 0000000..2ef4bdf --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/endpoint/CustomRequestEntityConverter.java @@ -0,0 +1,85 @@ +package dorun.project.routineapp.oauth.endpoint; + +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.security.PrivateKey; +import java.util.Date; +import org.apache.commons.io.FileUtils; +import org.apache.tomcat.util.http.fileupload.IOUtils; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.RequestEntity; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter; +import org.springframework.util.MultiValueMap; + +public class CustomRequestEntityConverter implements Converter> { + + private final OAuth2AuthorizationCodeGrantRequestEntityConverter defaultConverter; + + public CustomRequestEntityConverter() { + defaultConverter = new OAuth2AuthorizationCodeGrantRequestEntityConverter(); + } + + @Override + public RequestEntity convert(OAuth2AuthorizationCodeGrantRequest request) { + RequestEntity entity = defaultConverter.convert(request); + String registrationId = request.getClientRegistration() + .getRegistrationId(); + + MultiValueMap parameters = (MultiValueMap) entity.getBody(); + + if (registrationId.contains("apple")) { + parameters.set("client_secret", createAppleClientSecret(parameters.get("client_id").get(0), + parameters.get("client_secret").get(0))); + } + + return new RequestEntity<>(parameters, entity.getHeaders(), entity.getMethod(), entity.getUrl()); + } + + public String createAppleClientSecret(String clientId, String secretKeyResource) { + String clientSecret = ""; + String[] secretKeyResourceArr = secretKeyResource.split("/"); + try { + InputStream inputStream = new ClassPathResource("key/" + secretKeyResourceArr[0]).getInputStream(); + File file = File.createTempFile("appleKeyFile", ".p8"); + try { + FileUtils.copyInputStreamToFile(inputStream, file); + } finally { + IOUtils.closeQuietly(inputStream); + } + + String appleKeyId = secretKeyResourceArr[1]; + String appleTeamId = secretKeyResourceArr[2]; + + PEMParser pemParser = new PEMParser(new FileReader(file)); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + PrivateKeyInfo privateKeyInfo = (PrivateKeyInfo) pemParser.readObject(); + + PrivateKey privateKey = converter.getPrivateKey(privateKeyInfo); + + clientSecret = Jwts.builder() + .setHeaderParam(JwsHeader.KEY_ID, appleKeyId) + .setIssuer(appleTeamId) + .setAudience("https://appleid.apple.com") + .setSubject(clientId) + .setExpiration(new Date(System.currentTimeMillis() + (1000 * 60 * 5))) + .setIssuedAt(new Date(System.currentTimeMillis())) + .signWith(privateKey, SignatureAlgorithm.ES256) + .compact(); + + } catch (IOException e) { + + } + + return clientSecret; + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/entity/ProviderType.java b/src/main/java/dorun/project/routineapp/oauth/entity/ProviderType.java new file mode 100644 index 0000000..5520f06 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/entity/ProviderType.java @@ -0,0 +1,10 @@ +package dorun.project.routineapp.oauth.entity; + +import lombok.Getter; + +@Getter +public enum ProviderType { + APPLE, + GOOGLE, + KAKAO; +} diff --git a/src/main/java/dorun/project/routineapp/oauth/entity/RoleType.java b/src/main/java/dorun/project/routineapp/oauth/entity/RoleType.java new file mode 100644 index 0000000..2be66e3 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/entity/RoleType.java @@ -0,0 +1,23 @@ +package dorun.project.routineapp.oauth.entity; + +import java.util.Arrays; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum RoleType { + USER("ROLE_USER", "일반 사용자 권한"), + ADMIN("ROLE_ADMIN", "관리자 권한"), + GUEST("GUEST", "게스트 권한"); + + private final String code; + private final String displayName; + + public static RoleType of(String code) { + return Arrays.stream(RoleType.values()) + .filter(r -> r.getCode().equals(code)) + .findAny() + .orElse(GUEST); + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/entity/UserPrincipal.java b/src/main/java/dorun/project/routineapp/oauth/entity/UserPrincipal.java new file mode 100644 index 0000000..e5ddf76 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/entity/UserPrincipal.java @@ -0,0 +1,102 @@ +package dorun.project.routineapp.oauth.entity; + +import dorun.project.routineapp.api.entity.User; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +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.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.OAuth2User; + +@Getter +@Setter +@RequiredArgsConstructor +@AllArgsConstructor +public class UserPrincipal implements OAuth2User, UserDetails, OidcUser { + private final String userId; + private final String password; + private final ProviderType providerType; + private final RoleType roleType; + private final Collection authorities; + private Map attributes; + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getName() { + return userId; + } + + @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; + } + + @Override + public Map getClaims() { + return null; + } + + @Override + public OidcUserInfo getUserInfo() { + return null; + } + + @Override + public OidcIdToken getIdToken() { + return null; + } + + public static UserPrincipal create(User user) { + return new UserPrincipal( + user.getUserId(), + user.getPassword(), + user.getProviderType(), + RoleType.USER, + Collections.singletonList(new SimpleGrantedAuthority(RoleType.USER.getCode())) + ); + } + + public static UserPrincipal create(User user, Map attributes) { + UserPrincipal userPrincipal = create(user); + userPrincipal.setAttributes(attributes); + + return userPrincipal; + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/exception/NotValidTokenException.java b/src/main/java/dorun/project/routineapp/oauth/exception/NotValidTokenException.java new file mode 100644 index 0000000..fae2ea8 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/exception/NotValidTokenException.java @@ -0,0 +1,7 @@ +package dorun.project.routineapp.oauth.exception; + +public class NotValidTokenException extends RuntimeException { + public NotValidTokenException() { + super("토큰이 유효하지 않습니다."); + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/exception/OAuth2ProviderException.java b/src/main/java/dorun/project/routineapp/oauth/exception/OAuth2ProviderException.java new file mode 100644 index 0000000..88e1a8f --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/exception/OAuth2ProviderException.java @@ -0,0 +1,7 @@ +package dorun.project.routineapp.oauth.exception; + +public class OAuth2ProviderException extends RuntimeException { + public OAuth2ProviderException(String message) { + super(message); + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/exception/RestAuthenticationEntryPoint.java b/src/main/java/dorun/project/routineapp/oauth/exception/RestAuthenticationEntryPoint.java new file mode 100644 index 0000000..8543838 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/exception/RestAuthenticationEntryPoint.java @@ -0,0 +1,24 @@ +package dorun.project.routineapp.oauth.exception; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authenticationException + ) throws IOException, ServletException { +// authenticationException.printStackTrace(); + response.sendError( + HttpServletResponse.SC_UNAUTHORIZED, + authenticationException.getLocalizedMessage() + ); + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/filter/TokenAuthenticationFilter.java b/src/main/java/dorun/project/routineapp/oauth/filter/TokenAuthenticationFilter.java new file mode 100644 index 0000000..910400b --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/filter/TokenAuthenticationFilter.java @@ -0,0 +1,38 @@ +package dorun.project.routineapp.oauth.filter; + +import dorun.project.routineapp.oauth.token.Token; +import dorun.project.routineapp.oauth.token.TokenProvider; +import dorun.project.routineapp.oauth.util.HeaderUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +public class TokenAuthenticationFilter extends OncePerRequestFilter { + + private final TokenProvider tokenProvider; + + private static final String ACCESS_TOKEN = "access_token"; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String tokenString = HeaderUtil.getToken(request, ACCESS_TOKEN); + Token token = tokenProvider.convertToken(tokenString); + + if (token.validate()) { + Authentication authentication = tokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/handler/OAuth2AuthenticationFailureHandler.java b/src/main/java/dorun/project/routineapp/oauth/handler/OAuth2AuthenticationFailureHandler.java new file mode 100644 index 0000000..4093bed --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/handler/OAuth2AuthenticationFailureHandler.java @@ -0,0 +1,32 @@ +package dorun.project.routineapp.oauth.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +@RequiredArgsConstructor +public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + String redirectUri = request.getParameter("redirect_uri"); + +// exception.printStackTrace(); + + String targetUrl = UriComponentsBuilder.fromUriString(redirectUri) + .queryParam("error", exception.getLocalizedMessage()) + .build() + .toUriString(); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/handler/OAuth2AuthenticationSuccessHandler.java b/src/main/java/dorun/project/routineapp/oauth/handler/OAuth2AuthenticationSuccessHandler.java new file mode 100644 index 0000000..363ed1d --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/handler/OAuth2AuthenticationSuccessHandler.java @@ -0,0 +1,124 @@ +package dorun.project.routineapp.oauth.handler; + +import dorun.project.routineapp.api.entity.RefreshToken; +import dorun.project.routineapp.api.repository.RefreshTokenRepository; +import dorun.project.routineapp.config.properties.AppProperties; +import dorun.project.routineapp.oauth.entity.ProviderType; +import dorun.project.routineapp.oauth.entity.RoleType; +import dorun.project.routineapp.oauth.info.OAuth2UserInfo; +import dorun.project.routineapp.oauth.info.OAuth2UserInfoFactory; +import dorun.project.routineapp.oauth.token.Token; +import dorun.project.routineapp.oauth.token.TokenProvider; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URI; +import java.util.Collection; +import java.util.Date; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +@RequiredArgsConstructor +public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final TokenProvider tokenProvider; + private final AppProperties appProperties; + private final RefreshTokenRepository refreshTokenRepository; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + String targetUrl = determineTargetUrl(request, response, authentication); + + clearAuthenticationAttributes(request); + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + + protected String determineTargetUrl( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) { + String redirectUri = request.getParameter("redirect_uri"); + if (redirectUri.isEmpty() && !isAuthorizedRedirectUri(redirectUri)) { + throw new IllegalArgumentException("접근이 허용되지 않는 Redirect URI입니다."); + } + + OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication; + ProviderType providerType = ProviderType.valueOf(authToken.getAuthorizedClientRegistrationId().toUpperCase()); + + OidcUser user = (OidcUser) authentication.getPrincipal(); + OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, user.getAttributes()); + Collection authorities = user.getAuthorities(); + + RoleType roleType = hasAuthority(authorities, RoleType.ADMIN.getCode()) ? RoleType.ADMIN : RoleType.USER; + + Date now = new Date(); + Token accessToken = tokenProvider.createToken( + userInfo.getId(), + roleType.getCode(), + new Date(now.getTime() + appProperties.getAuth().getTokenExpiry()) + ); + + long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry(); + + Token refreshToken = tokenProvider.createToken( + appProperties.getAuth().getTokenSecret(), + new Date(now.getTime() + refreshTokenExpiry) + ); + + Optional searchedRefreshToken = refreshTokenRepository.findByUserId(userInfo.getId()); + RefreshToken userRefreshToken; + if (searchedRefreshToken.isEmpty()) { + String uuid = UUID.randomUUID().toString(); + userRefreshToken = new RefreshToken(uuid, userInfo.getId(), refreshToken.getToken()); + refreshTokenRepository.save(userRefreshToken); + } else { + userRefreshToken = searchedRefreshToken.get(); + userRefreshToken.updateRefreshToken(refreshToken.getToken()); + } + + response.addHeader("refresh_token", refreshToken.getToken()); + + return UriComponentsBuilder.fromUriString(redirectUri) + .queryParam("token", accessToken.getToken()) + .build() + .toUriString(); + } + + private boolean hasAuthority(Collection authorities, String authority) { + if (authorities == null) { + return false; + } + + for (GrantedAuthority grantedAuthority : authorities) { + if (authority.equals(grantedAuthority.getAuthority())) { + return true; + } + } + + return false; + } + + private boolean isAuthorizedRedirectUri(String uri) { + URI clientRedirectUri = URI.create(uri); + + return appProperties.getOauth2().getAuthorizedRedirectUris().stream() + .anyMatch(authorizedRedirectUri -> { + URI authorizedURI = URI.create(authorizedRedirectUri); + return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost()) + && authorizedURI.getPort() == clientRedirectUri.getPort(); + }); + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/handler/TokenAccessDeniedHandler.java b/src/main/java/dorun/project/routineapp/oauth/handler/TokenAccessDeniedHandler.java new file mode 100644 index 0000000..9910dbb --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/handler/TokenAccessDeniedHandler.java @@ -0,0 +1,23 @@ +package dorun.project.routineapp.oauth.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerExceptionResolver; +@Component +@RequiredArgsConstructor +public class TokenAccessDeniedHandler implements AccessDeniedHandler { + private final HandlerExceptionResolver handlerExceptionResolver; + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + handlerExceptionResolver.resolveException(request, response, null, accessDeniedException); + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/info/OAuth2UserInfo.java b/src/main/java/dorun/project/routineapp/oauth/info/OAuth2UserInfo.java new file mode 100644 index 0000000..57b1931 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/info/OAuth2UserInfo.java @@ -0,0 +1,20 @@ +package dorun.project.routineapp.oauth.info; + +import java.util.Map; + +public abstract class OAuth2UserInfo { + + protected Map attributes; + + public OAuth2UserInfo(Map attributes) { + this.attributes = attributes; + } + + public Map getAttributes() { + return attributes; + } + + public abstract String getId(); + public abstract String getName(); + public abstract String getEmail(); +} diff --git a/src/main/java/dorun/project/routineapp/oauth/info/OAuth2UserInfoFactory.java b/src/main/java/dorun/project/routineapp/oauth/info/OAuth2UserInfoFactory.java new file mode 100644 index 0000000..8140fa6 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/info/OAuth2UserInfoFactory.java @@ -0,0 +1,18 @@ +package dorun.project.routineapp.oauth.info; + +import dorun.project.routineapp.oauth.entity.ProviderType; +import dorun.project.routineapp.oauth.info.platform.AppleOAuth2UserInfo; +import dorun.project.routineapp.oauth.info.platform.GoogleOAuth2UserInfo; +import dorun.project.routineapp.oauth.info.platform.KakaoOAuth2UserInfo; +import java.util.Map; + +public class OAuth2UserInfoFactory { + public static OAuth2UserInfo getOAuth2UserInfo(ProviderType providerType, Map attributes) { + switch (providerType) { + case APPLE: return new AppleOAuth2UserInfo(attributes); + case GOOGLE: return new GoogleOAuth2UserInfo(attributes); + case KAKAO: return new KakaoOAuth2UserInfo(attributes); + default: throw new IllegalArgumentException("유효하지 않은 ProviderType입니다."); + } + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/info/platform/AppleOAuth2UserInfo.java b/src/main/java/dorun/project/routineapp/oauth/info/platform/AppleOAuth2UserInfo.java new file mode 100644 index 0000000..38c86ed --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/info/platform/AppleOAuth2UserInfo.java @@ -0,0 +1,25 @@ +package dorun.project.routineapp.oauth.info.platform; + +import dorun.project.routineapp.oauth.info.OAuth2UserInfo; +import java.util.Map; + +public class AppleOAuth2UserInfo extends OAuth2UserInfo { + public AppleOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return "id"; + } + + @Override + public String getName() { + return (String) attributes.get("name"); + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/info/platform/GoogleOAuth2UserInfo.java b/src/main/java/dorun/project/routineapp/oauth/info/platform/GoogleOAuth2UserInfo.java new file mode 100644 index 0000000..38104ec --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/info/platform/GoogleOAuth2UserInfo.java @@ -0,0 +1,25 @@ +package dorun.project.routineapp.oauth.info.platform; + +import dorun.project.routineapp.oauth.info.OAuth2UserInfo; +import java.util.Map; + +public class GoogleOAuth2UserInfo extends OAuth2UserInfo { + public GoogleOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return (String) attributes.get("sub"); + } + + @Override + public String getName() { + return (String) attributes.get("name"); + } + + @Override + public String getEmail() { + return (String) attributes.get("email"); + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/info/platform/KakaoOAuth2UserInfo.java b/src/main/java/dorun/project/routineapp/oauth/info/platform/KakaoOAuth2UserInfo.java new file mode 100644 index 0000000..6a86ac4 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/info/platform/KakaoOAuth2UserInfo.java @@ -0,0 +1,31 @@ +package dorun.project.routineapp.oauth.info.platform; + +import dorun.project.routineapp.oauth.info.OAuth2UserInfo; +import java.util.Map; + +public class KakaoOAuth2UserInfo extends OAuth2UserInfo { + public KakaoOAuth2UserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getId() { + return (String) attributes.get("id"); + } + + @Override + public String getName() { + Map properties = (Map) attributes.get("properties"); + + if (properties == null) { + return null; + } + + return (String) attributes.get("nickname"); + } + + @Override + public String getEmail() { + return (String) attributes.get("account_email"); + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/service/CustomOAuth2UserService.java b/src/main/java/dorun/project/routineapp/oauth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..3a5376c --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/service/CustomOAuth2UserService.java @@ -0,0 +1,109 @@ +package dorun.project.routineapp.oauth.service; + +import dorun.project.routineapp.api.entity.User; +import dorun.project.routineapp.api.repository.UserRepository; +import dorun.project.routineapp.oauth.entity.ProviderType; +import dorun.project.routineapp.oauth.entity.RoleType; +import dorun.project.routineapp.oauth.entity.UserPrincipal; +import dorun.project.routineapp.oauth.exception.OAuth2ProviderException; +import dorun.project.routineapp.oauth.info.OAuth2UserInfo; +import dorun.project.routineapp.oauth.info.OAuth2UserInfoFactory; +import dorun.project.routineapp.oauth.token.Token; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import javax.naming.AuthenticationException; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + private static final Logger logger = LoggerFactory.getLogger(CustomOAuth2UserService.class); + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + ProviderType providerType = ProviderType.valueOf(userRequest + .getClientRegistration() + .getRegistrationId() + .toUpperCase()); + + Map userAttributes = getAttributes(userRequest, providerType); + + try { + OAuth2UserInfo userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(providerType, userAttributes); + User savedUser = userRepository.findByUserId(userInfo.getId()); + + if (savedUser != null) { + if (providerType != savedUser.getProviderType()) { + throw new OAuth2ProviderException(savedUser.getProviderType() + "계정으로 가입하셨습니다." + + "해당 플랫폼을 통해 다시 로그인 해 주세요."); + } // 연동을 시켜버릴까? + updateUser(savedUser, userInfo); + } else { + savedUser = createUser(userInfo, providerType); + } + return UserPrincipal.create(savedUser, userAttributes); +// } catch (AuthenticationException e) { +// throw e; + } catch (Exception e) { + throw new InternalAuthenticationServiceException(e.getMessage(), e.getCause()); + } + } + + private User createUser(OAuth2UserInfo userInfo, ProviderType providerType) { + return User.builder() + .userId(userInfo.getId()) + .username(userInfo.getName()) + .password("NO_PASS") + .email(userInfo.getEmail()) + .providerType(providerType) + .roleType(RoleType.USER) + .build(); + } + + private User updateUser(User user, OAuth2UserInfo userInfo) { + if (userInfo.getName() != null && !user.getUsername().equals(userInfo.getName())) { + user.setUsername(userInfo.getName()); + } + return user; + } + + private Map getAttributes(OAuth2UserRequest request, ProviderType providerType) { + if (providerType.equals(ProviderType.APPLE)) { + return decodeAppleJwtTokenPayload(request.getAdditionalParameters().get("id_token").toString()); + } + OAuth2User user = super.loadUser(request); + return user.getAttributes(); + } + private Map decodeAppleJwtTokenPayload(String appleIdToken) { + Map jwtClaims = new HashMap<>(); + try { + String[] parts = appleIdToken.split("\\."); + Base64.Decoder decoder = Base64.getUrlDecoder(); + + byte[] decodedBytes = decoder.decode(parts[1].getBytes(StandardCharsets.UTF_8)); + String decodedString = new String(decodedBytes, StandardCharsets.UTF_8); + ObjectMapper mapper = new ObjectMapper(); + + Map map = mapper.readValue(decodedString, Map.class); + jwtClaims.putAll(map); + jwtClaims.put("id_token", appleIdToken); + } catch (JsonProcessingException e) { + logger.error("decodeJwtToken: {}-{} / jwtToken : {}", e.getMessage(), e.getCause(), appleIdToken); + } + return jwtClaims; + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/service/CustomUserDetailsService.java b/src/main/java/dorun/project/routineapp/oauth/service/CustomUserDetailsService.java new file mode 100644 index 0000000..2fcdf12 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/service/CustomUserDetailsService.java @@ -0,0 +1,26 @@ +package dorun.project.routineapp.oauth.service; + +import dorun.project.routineapp.api.entity.User; +import dorun.project.routineapp.api.repository.UserRepository; +import dorun.project.routineapp.oauth.entity.UserPrincipal; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUserId(username); + if (user == null) { + throw new UsernameNotFoundException("사용자 이름을 찾을 수 없습니다."); + } + return UserPrincipal.create(user); + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/token/Token.java b/src/main/java/dorun/project/routineapp/oauth/token/Token.java new file mode 100644 index 0000000..774b7ee --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/token/Token.java @@ -0,0 +1,90 @@ +package dorun.project.routineapp.oauth.token; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; +import java.security.Key; +import java.util.Date; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@RequiredArgsConstructor +public class Token { + @Getter + private final String token; + private final Key key; + + private static final Logger logger = LoggerFactory.getLogger(Token.class); + private static final String AUTHORITIES_KEY = "role"; + + Token(String id, Date validity, Key key) { + this.token = createToken(id, validity); + this.key = key; + } + + Token(String id, String role, Date validity, Key key) { + this.token = createToken(id, role, validity); + this.key = key; + } + + private String createToken(String id, Date validity) { + return Jwts.builder() + .setSubject(id) + .signWith(key, SignatureAlgorithm.HS256) + .setExpiration(validity) + .compact(); + } + + private String createToken(String id, String role, Date validity) { + return Jwts.builder() + .setSubject(id) + .claim(AUTHORITIES_KEY, role) + .signWith(key, SignatureAlgorithm.HS256) + .setExpiration(validity) + .compact(); + } + + public boolean validate() { + return this.getTokenClaims() != null; + } + + public Claims getTokenClaims() { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (SecurityException e) { + logger.debug("유효하지 않은 JWT 서명입니다."); + } catch (MalformedJwtException e) { + logger.debug("유효하지 않은 JWT 토큰입니다."); + } catch (ExpiredJwtException e) { + logger.debug("만료된 JWT 토큰입니다."); + } catch (UnsupportedJwtException e) { + logger.debug("지원하지 않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { + logger.debug("잘못된 JWT 토큰입니다."); + } + return null; + } + + public Claims getExpiredTokenClaims() { + try { + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + logger.debug("만료된 JWT 토큰입니다."); + return e.getClaims(); + } + return null; + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/token/TokenProvider.java b/src/main/java/dorun/project/routineapp/oauth/token/TokenProvider.java new file mode 100644 index 0000000..2a5ccb0 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/token/TokenProvider.java @@ -0,0 +1,51 @@ +package dorun.project.routineapp.oauth.token; + +import dorun.project.routineapp.oauth.exception.NotValidTokenException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; + +public class TokenProvider { + private final Key key; + private static final String AUTHORITIES_KEY = "role"; + + public TokenProvider(String secret) { + this.key = Keys.hmacShaKeyFor(secret.getBytes()); + } + + public Token createToken(String id, Date validity) { + return new Token(id, validity, key); + } + + public Token createToken(String id, String role, Date validity) { + return new Token(id, role, validity, key); + } + + public Token convertToken(String token) { + return new Token(token, key); + } + + public Authentication getAuthentication(Token token) { + if (token.validate()) { + Claims claims = token.getTokenClaims(); + Collection authorities = + Arrays.stream(new String[]{claims.get(AUTHORITIES_KEY).toString()}) + .map(SimpleGrantedAuthority::new) + .toList(); + + User principal = new User(claims.getSubject(), "", authorities); + + return new UsernamePasswordAuthenticationToken(principal, token, authorities); + } else { + throw new NotValidTokenException(); + } + } +} diff --git a/src/main/java/dorun/project/routineapp/oauth/util/HeaderUtil.java b/src/main/java/dorun/project/routineapp/oauth/util/HeaderUtil.java new file mode 100644 index 0000000..3378139 --- /dev/null +++ b/src/main/java/dorun/project/routineapp/oauth/util/HeaderUtil.java @@ -0,0 +1,17 @@ +package dorun.project.routineapp.oauth.util; + +import jakarta.servlet.http.HttpServletRequest; + +public class HeaderUtil { + private static final String TOKEN_PREFIX = "Bearer "; + + public static String getToken(HttpServletRequest request, String division) { + String header = request.getHeader(division); + + if (header != null && header.startsWith(TOKEN_PREFIX)) { + return header.substring(TOKEN_PREFIX.length()); + } + + return null; + } +}