사이드 프로젝트를 만들며 소셜 로그인의 OAuth2.0 인증방식을 개발해보려고 한다.
인터넷에 소개되어있는 블로그 글들은 서칭 후 코딩해보고 정리하였다.
카카오, 네이버, 구글만 구현할것인데 나머지 소셜들도 대부분 비슷하다고 보면 된다.
OAuth2.0란? 아래글 참조
1. Build Setting
build.gradle
Spring Boot는 2.3.1.RELEASE 버전을 사용했다.
plugins {
id 'org.springframework.boot' version '2.3.1.RELEASE'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
dependencies {
// boot starter
implementation 'org.springframework.boot:spring-boot-starter'
// configuration
compileOnly "org.springframework.boot:spring-boot-configuration-processor"
// JDBC
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
// MySQL
runtimeOnly 'mysql:mysql-connector-java'
// REST
implementation 'org.springframework.boot:spring-boot-starter-web'
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
// OAuth 2.0
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
// JWT
implementation 'io.jsonwebtoken:jjwt:0.9.1'
// JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// lang3
implementation 'org.apache.commons:commons-lang3:3.11'
// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}
test {
useJUnitPlatform()
}
application.yml
설정 파일은 3가지로 분리해서 적용하였다.
한번에 관리하고 싶으신분들은 따로 하나로 정의하시길 바란다.
- connection : DB 정보와 JPA 설정 정보
- oauth2 : oauth2에 필요한 정보
- app : token 정보와 허용 redirect uri 정보
spring:
profiles:
include: connection, oauth2, app
application-connection.yml
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/oauth2?serverTimezone=Asia/Seoul&useSSL=false&characterEncoding=utf8&allowPublicKeyRetrieval=true
username: root
password: mysqlroot
jpa:
database: mysql
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
generate-ddl: false
open-in-view: false
hibernate:
ddl-auto: validate
use-new-id-generator-mappings: false
show-sql: true
properties:
hibernate:
enable_lazy_load_no_trans: true
format_sql: true
application-oauth2.yml
구글, 카카오, 네이버에서 어플리케이션을 등록하고 id 와 secret key를 발급받아서 설정한다.
발급받는 방법은 모두 아실꺼라 작성하진 않겠다.
redirect-uri는 프론트 서버로 설정해 프론트에서 /oauth2/callback/{registrationId}로 Token 발급을 요청하지만
프론트 서버를 만들지 않아 그냥 바로 /oauth2/callback/{registrationId}로 redirect 하도록 설정하였다.
redirect-uri는 각 소셜 developers에서 등록해줘야 정상적으로 인증이 가능하다.
spring:
profiles:
include: google, kakao, naver
---
spring:
profiles: google
security:
oauth2:
client:
registration:
google:
client-name: google
client-id: [발급받은 client id]
client-secret: [발급받은 client secret]
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
authorization-grant-type: authorization_code
scope:
- email
- profile
---
spring:
profiles: kakao
security:
oauth2:
client:
registration:
kakao:
client-name: kakao
client-id: [발급받은 client id]
client-secret: [발급받은 client secret]
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
authorization-grant-type: authorization_code
scope:
- account_email
- profile_nickname
- profile_image
client-authentication-method: POST
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
user-info-uri: https://kapi.kakao.com/v2/user/me
token-uri: https://kauth.kakao.com/oauth/token
user-name-attribute: id
---
spring:
profiles: naver
security:
oauth2:
client:
registration:
naver:
client-name: naver
client-id: [발급받은 client id]
client-secret: [발급받은 client secret]
redirect-uri: "{baseUrl}/oauth2/callback/{registrationId}"
authorization-grant-type: authorization_code
scope:
- email
- name
- profile_image
provider:
naver:
authorization-uri: https://nid.naver.com/oauth2.0/authorize
user-info-uri: https://openapi.naver.com/v1/nid/me
token-uri: https://nid.naver.com/oauth2.0/token
user-name-attribute: response
application-app.yml
토큰 유효기간은 14일(24 x 60 x 60 x 1000 * 14)로 설정하였다.
app:
auth:
token-secret: devbeekei-token-secret-key
token-expiration-time: 1209600000
oauth2:
authorized-redirect-uris:
- http://localhost:8080/auth/token
Application
@EnableConfigurationProperties(AppProperties.class)를 추가한다.
사용할 property class를 정의한다.
@EnableConfigurationProperties(AppProperties.class)
@SpringBootApplication
public class SpringSecurityOauth2Application {
public static void main(String[] args) {
SpringApplication.run(SpringSecurityOauth2Application.class, args);
}
}
2. Config
WebMvcConfig
CORS를 설정한다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
// 1시간
private final long MAX_AGE_SECS = 3600;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
.addMapping("/**")
.allowedOrigins("*") // 외부에서 들어오는 모든 url을 허용
.allowedMethods("GET", "POST", "PUT", "DELETE") // 허용되는 Method
.allowedHeaders("*") // 허용되는 헤더
.allowCredentials(true) // 자격증명 허용
.maxAge(MAX_AGE_SECS); // 허용 시간
}
}
SecurityConfig
Spring Security 보안을 설정한다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(
securedEnabled = true,
jsr250Enabled = true,
prePostEnabled = true
)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final CustomUserDetailsService customUserDetailsService;
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;
@Bean
public TokenAuthenticationFilter tokenAuthenticationFilter() {
return new TokenAuthenticationFilter();
}
@Bean
public HttpCookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationRequestRepository() {
return new HttpCookieOAuth2AuthorizationRequestRepository();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean(BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(customUserDetailsService)
.passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션을 사용하지 않음
.and()
.csrf().disable() // csrf 미사용
.headers().frameOptions().disable()
.and()
.formLogin().disable() // 로그인 폼 미사용
.httpBasic().disable() // Http basic Auth 기반으로 로그인 인증창이 열림(disable 시 인증창 열리지 않음)
.exceptionHandling().authenticationEntryPoint(new RestAuthenticationEntryPoint()) // 인증,인가가 되지 않은 요청 시 발생
.and()
.authorizeRequests()
.antMatchers("/auth/**", "/oauth2/**").permitAll() // Security 허용 Url
.anyRequest().authenticated() // 그 외엔 모두 인증 필요
.and()
.oauth2Login()
.authorizationEndpoint().baseUri("/oauth2/authorization") // 소셜 로그인 Url
.authorizationRequestRepository(cookieOAuth2AuthorizationRequestRepository()) // 인증 요청을 쿠키에 저장하고 검색
.and()
.redirectionEndpoint().baseUri("/oauth2/callback/*") // 소셜 인증 후 Redirect Url
.and()
.userInfoEndpoint().userService(customOAuth2UserService) // 소셜의 회원 정보를 받아와 가공처리
.and()
.successHandler(oAuth2AuthenticationSuccessHandler) // 인증 성공 시 Handler
.failureHandler(oAuth2AuthenticationFailureHandler); // 인증 실패 시 Handler
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
AppProperties
app property 파일에 설정된 정보들을 객체로 매핑한다.
@Getter
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private final Auth auth = new Auth();
private final OAuth2 oauth2 = new OAuth2();
@Getter
@Setter
public static class Auth {
private String tokenSecret;
private long tokenExpirationTime;
}
@Getter
public static class OAuth2 {
private List<String> authorizedRedirectUris = new ArrayList<>();
public OAuth2 authorizedRedirectUris(List<String> authorizedRedirectUris) {
this.authorizedRedirectUris = authorizedRedirectUris;
return this;
}
}
}
3. Exception
Exception은 본인에 맞게 설계해도 상관없을듯 하다.
BadRequestException
잘못된 요청 시 발생한다.
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
public BadRequestException(String message, Throwable cause) {
super(message, cause);
}
}
OAuth2AuthenticationProcessingException
인증중에 오류가 발생할때 발생한다.
public class OAuth2AuthenticationProcessingException extends AuthenticationException {
public OAuth2AuthenticationProcessingException(String msg, Throwable t) {
super(msg, t);
}
public OAuth2AuthenticationProcessingException(String msg) {
super(msg);
}
}
ResourceNotFoundException
리소스가 존재하지 않을때 발생한다.
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
private String resourceName;
private String fieldName;
private Object fieldValue;
public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue));
this.resourceName = resourceName;
this.fieldName = fieldName;
this.fieldValue = fieldValue;
}
public String getResourceName() {
return resourceName;
}
public String getFieldName() {
return fieldName;
}
public Object getFieldValue() {
return fieldValue;
}
}
4. Entity
JPA를 이용해 entity를 구축한다.
엔티티 관계도는 간단하게 설정하였다.
User
회원 Entity
@ToString(exclude = "socialAuth")
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "user", uniqueConstraints = {
@UniqueConstraint(columnNames = "email")
})
@SecondaryTables({
@SecondaryTable(name = "social_auth", pkJoinColumns = @PrimaryKeyJoinColumn(name = "user_id"))
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id", nullable = false)
private long id;
@Column(name = "email", length = 200, nullable = false)
private String email;
@Column(name = "password")
private String password;
@Column(name = "nickname", length = 50, nullable = false)
private String nickname;
@Column(name = "tel", length = 20)
private String tel;
@CreationTimestamp
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "created", nullable = false, updatable = false)
private Date created;
@UpdateTimestamp
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "updated", nullable = false)
private Date updated;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "providerId", column = @Column(table = "social_auth", name = "provider_id")),
@AttributeOverride(name = "provider", column = @Column(table = "social_auth", name = "provider")),
@AttributeOverride(name = "email", column = @Column(table = "social_auth", name = "email", length = 100, nullable = false)),
@AttributeOverride(name = "name", column = @Column(table = "social_auth", name = "name", length = 100, nullable = false)),
@AttributeOverride(name = "imageUrl", column = @Column(table = "social_auth", name = "image_url", columnDefinition = "TEXT")),
@AttributeOverride(name = "attributes", column = @Column(table = "social_auth", name = "attributes", columnDefinition = "TEXT")),
@AttributeOverride(name = "ip", column = @Column(table = "social_auth", name = "ip", length = 30, nullable = false)),
})
private SocialAuth socialAuth;
}
SocialAuth
소셜 인증 정보를 저장한다.
@Getter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Embeddable
public class SocialAuth {
private String providerId;
@Enumerated(value = EnumType.STRING)
private AuthProvider provider;
private String email;
private String name;
private String imageUrl;
private String attributes;
private String ip;
public void update(String name, String imageUrl, Map<String, Object> attributes) {
this.name = name;
this.imageUrl = imageUrl;
this.attributes = attributes.toString();
}
}
AuthProvider
소셜 유형
public enum AuthProvider {
google,
kakao,
naver
}
UserRole
회원의 권한 부여
public enum UserRole { CLIENT, ADMIN }
5. DTO
소셜에서 받은 데이터를 매핑할 객체를 정의한다.
OAuth2UserInfoFactory
받아온 데이터를 각 소셜에 맞게 객체와 매핑한다.
public class OAuth2UserInfoFactory {
public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes) {
if (registrationId.equalsIgnoreCase(AuthProvider.google.toString())) {
return new GoogleOAuth2UserInfo(attributes);
} else if (registrationId.equalsIgnoreCase(AuthProvider.naver.toString())) {
return new NaverOAuth2UserInfo(attributes);
} else if (registrationId.equalsIgnoreCase(AuthProvider.kakao.toString())) {
return new KakaoOAuth2UserInfo(attributes);
} else {
throw new OAuth2AuthenticationProcessingException("Unsupported Login Type : " + registrationId);
}
}
}
OAuth2UserInfo
각 소셜마다 받아오는 데이터의 JSON 형태가 다르므로 기본적으로 필요한 정보를 추상화하고 상속받아 구현한다.
@Getter
@AllArgsConstructor
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public abstract String getId();
public abstract String getEmail();
public abstract String getName();
public abstract String getImageUrl();
}
GoogleOAuth2UserInfo
public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("sub");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
KakaoOAuth2UserInfo
public class KakaoOAuth2UserInfo extends OAuth2UserInfo {
private Integer id;
public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
super((Map<String, Object>) attributes.get("kakao_account"));
this.id = (Integer) attributes.get("id");
}
@Override
public String getId() {
return this.id.toString();
}
@Override
public String getName() {
return (String) ((Map<String, Object>) attributes.get("profile")).get("nickname");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) ((Map<String, Object>) attributes.get("profile")).get("thumbnail_image_url");
}
}
NaverOAuth2UserInfo
public class NaverOAuth2UserInfo extends OAuth2UserInfo {
public NaverOAuth2UserInfo(Map<String, Object> attributes) {
super((Map<String, Object>) attributes.get("response"));
}
@Override
public String getId() {
return (String) attributes.get("id");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getImageUrl() {
return (String) attributes.get("profile_image");
}
}
6. Util
CookieUtils
쿠키를 관리하는 Util Class
public class CookieUtils {
public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(name)) {
return Optional.of(cookie);
}
}
}
return Optional.empty();
}
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(name)) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
}
public static String serialize(Object object) {
return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(object));
}
public static <T> T deserialize(Cookie cookie, Class<T> cls) {
return cls.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue())));
}
}
TokenProvider
JWT를 발급하고 인증하는 Util Class
@AllArgsConstructor
@Service
public class TokenProvider {
private AppProperties appProperties;
public String creatToken(Authentication authentication) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + appProperties.getAuth().getTokenExpirationTime());
return Jwts.builder()
.setSubject(Long.toString(userPrincipal.getId()))
.setIssuedAt(new Date())
.setExpiration(expiryDate)
.signWith(SignatureAlgorithm.HS512, appProperties.getAuth().getTokenSecret())
.compact();
}
public Long getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.setSigningKey(appProperties.getAuth().getTokenSecret())
.parseClaimsJws(token)
.getBody();
return Long.parseLong(claims.getSubject());
}
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(appProperties.getAuth().getTokenSecret()).parseClaimsJws(token);
return true;
} catch (SignatureException e) { // 유효하지 않은 JWT 서명
throw new OAuth2AuthenticationProcessingException("not valid jwt signature");
} catch (MalformedJwtException e) { // 유효하지 않은 JWT
throw new OAuth2AuthenticationProcessingException("not valid jwt");
} catch (io.jsonwebtoken.ExpiredJwtException e) { // 만료된 JWT
throw new OAuth2AuthenticationProcessingException("expired jwt");
} catch (io.jsonwebtoken.UnsupportedJwtException e) { // 지원하지 않는 JWT
throw new OAuth2AuthenticationProcessingException("unsupported jwt");
} catch (IllegalArgumentException e) { // 빈값
throw new OAuth2AuthenticationProcessingException("empty jwt");
}
}
}
7. Security
UserPrincipal
Spring Security에서 사용하는 인증 객체
@Getter
@AllArgsConstructor
public class UserPrincipal implements OAuth2User, UserDetails {
private long id;
private String email;
private String password;
private Collection<? extends GrantedAuthority> authorities;
@Setter
private Map<String, Object> attributes;
public static UserPrincipal create(User user) {
List<GrantedAuthority> authorities =
Collections.singletonList(new SimpleGrantedAuthority("" + UserRole.CLIENT));
return new UserPrincipal(
user.getId(),
user.getEmail(),
user.getPassword(),
authorities,
null
);
}
public static UserPrincipal create(User user, Map<String, Object> attributes) {
UserPrincipal userPrincipal = UserPrincipal.create(user);
userPrincipal.setAttributes(attributes);
return userPrincipal;
}
@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 String getName() {
return String.valueOf(id);
}
@Override
public String getUsername() {
return email;
}
}
HttpCookieOAuth2AuthorizationRequestRepository
요청받은 인증정보를 쿠키에 저장하고 검색한다.
@Component
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
private static final int cookieExpireSeconds = 180;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
OAuth2AuthorizationRequest oAuth2AuthorizationRequest = CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
return oAuth2AuthorizationRequest;
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
removeAuthorizationRequest(request, response);
return;
}
CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
}
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
return this.loadAuthorizationRequest(request);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
}
}
CustomOAuth2UserService
받아온 소셜 계정 정보를 OAuth2UserInfo로 반환하고 DB에 등록 및 수정을 진행한다.
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest);
try {
return processOAuth2User(oAuth2UserRequest, oAuth2User);
} catch (AuthenticationException e) {
throw e;
} catch (Exception e) {
throw new InternalAuthenticationServiceException(e.getMessage(), e.getCause());
}
}
private OAuth2User processOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) {
OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(
oAuth2UserRequest.getClientRegistration().getRegistrationId(),
oAuth2User.getAttributes()
);
if (StringUtils.isBlank(oAuth2UserInfo.getEmail()))
throw new OAuth2AuthenticationProcessingException("empty email");
Optional<User> userOptional = userRepository.findFirstByEmailOrderByIdAsc(oAuth2UserInfo.getEmail());
User user;
if (userOptional.isPresent()) {
if (!userOptional.get().getSocialAuth().getProvider().equals(AuthProvider.valueOf(oAuth2UserRequest.getClientRegistration().getRegistrationId())))
throw new OAuth2AuthenticationProcessingException("already sign up other provider");
user = updateUser(userOptional.get(), oAuth2UserInfo);
} else {
user = registerUser(oAuth2UserRequest, oAuth2UserInfo);
}
return UserPrincipal.create(user, oAuth2User.getAttributes());
}
private User registerUser(OAuth2UserRequest oAuth2UserRequest, OAuth2UserInfo oAuth2UserInfo) {
return userRepository.save(User.builder()
.email(oAuth2UserInfo.getEmail())
.nickname(oAuth2UserInfo.getName())
.socialAuth(SocialAuth.builder()
.providerId(oAuth2UserInfo.getId())
.provider(AuthProvider.valueOf(oAuth2UserRequest.getClientRegistration().getRegistrationId()))
.email(oAuth2UserInfo.getEmail())
.name(oAuth2UserInfo.getName())
.imageUrl(oAuth2UserInfo.getImageUrl())
.attributes(oAuth2UserInfo.getAttributes().toString())
.ip("127.0.0.1")
.build())
.build());
}
private User updateUser(User user, OAuth2UserInfo oAuth2UserInfo) {
user.getSocialAuth().update(oAuth2UserInfo.getName(), oAuth2UserInfo.getImageUrl(), oAuth2UserInfo.getAttributes());
return user;
}
}
OAuth2AuthenticationSuccessHandler
소셜 인증에 성공 했을때 Handler
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final TokenProvider tokenProvider;
private final AppProperties appProperties;
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
String targetUrl = determineTargetUrl(request, response, authentication);
if (response.isCommitted()) {
logger.debug("response has already been committed. unable to redirect to " + targetUrl);
return;
}
clearAuthenticationAttributes(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Optional<String> redirectUri = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME).map(Cookie::getValue);
if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get()))
throw new BadRequestException("unauthorized Redirect URI");
String targetUri = redirectUri.orElse(getDefaultTargetUrl());
String token = tokenProvider.creatToken(authentication);
return UriComponentsBuilder.fromUriString(targetUri)
.queryParam("error", "")
.queryParam("token", token)
.build().toUriString();
}
protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
}
private boolean isAuthorizedRedirectUri(String uri) {
URI clientRedirectUri = URI.create(uri);
return appProperties.getOauth2().getAuthorizedRedirectUris()
.stream()
.anyMatch(authorizedRedirectUri -> {
URI authorizedURI = URI.create(authorizedRedirectUri);
if (authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost()) && authorizedURI.getPort() == clientRedirectUri.getPort()) {
return true;
}
return false;
});
}
}
OAuth2AuthenticationFailureHandler
소셜 인증에 실패 했을때 Handler
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
String targetUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue)
.orElse(("/"));
targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("token", "")
.queryParam("error", exception.getLocalizedMessage())
.build().toUriString();
httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
TokenAuthenticationFilter
토큰을 검증하는 필터
public class TokenAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private TokenProvider tokenProvider;
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
String jwt = getJwtFromRequest(request);
if (StringUtils.isNotBlank(jwt) && tokenProvider.validateToken(jwt)) {
Long userId = tokenProvider.getUserIdFromToken(jwt);
UserDetails userDetails = customUserDetailsService.loadUserById(userId);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.isNotBlank(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
CustomUserDetailsService
Token을 검증하는 Filter에서 회원 데이터를 조회해 인증 객체로 반환한다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
User user = userRepository.findFirstByEmailOrderByIdAsc(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found with email : " + email));
return UserPrincipal.create(user);
}
@Transactional
public UserDetails loadUserById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UsernameNotFoundException("User not found with id : " + id));
return UserPrincipal.create(user);
}
}
RestAuthenticationEntryPoint
인증 및 인가가 되지않은 요청 시 발생한다.
@Slf4j
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
AuthenticationException e) throws IOException {
log.error("unapproved error : ", e.getMessage());
httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED,
e.getLocalizedMessage());
}
}
Controller를 만들어서 토큰이 발급되는지 확인해보자
@RestController
@RequestMapping("auth")
public class AuthController {
@GetMapping(value = "token")
public String token(@RequestParam String token, @RequestParam String error) {
if (StringUtils.isNotBlank(error)) {
return error;
} else {
return token;
}
}
}
1. http://localhost:8080/oauth2/authorization/kakao?redirect_uri=http://localhost:8080/auth/token로 접속하면 카카오 로그인 페이지가 나온다.
2. 로그인을 하면 전달한 redirect_uri로 token값을 보내준다.
3. 정상적으로 토큰이 발급됬으면 아래와 같이 토큰이 찍힌다.
전체 코드는 아래 GitHub에서 확인할 수 있다.
'Spring' 카테고리의 다른 글
Spring Boot + FCM 웹 푸시 사용하기 (0) | 2021.12.02 |
---|---|
Spring Boot Validation 어노테이션 정리 (0) | 2021.11.10 |
Spring Cloud Config 소개 & 예제 (0) | 2021.10.18 |
Spring Boot + log4j2 (0) | 2021.10.07 |
Spring Boot + Flyway를 이용한 데이터베이스 마이그레이션 (0) | 2021.09.16 |