사이드 프로젝트를 만들며 소셜 로그인의 OAuth2.0 인증방식을 개발해보려고 한다.
인터넷에 소개되어있는 블로그 글들은 서칭 후 코딩해보고 정리하였다.
카카오, 네이버, 구글만 구현할것인데 나머지 소셜들도 대부분 비슷하다고 보면 된다.
OAuth2.0란? 아래글 참조
OAuth2(Open Authentication2)
OAuth2란? 다양한 플랫폼 환경에서 인증 및 권한 부여를 위한 표준 프로토콜 OAuth 방식이 등장하기 전에는 다른 서비스의 리소스를 가져오기 위해서는 다른 사이트의 ID와 PW를 직접 입력받아 저장
devbksheen.tistory.com
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에서 확인할 수 있다.
GitHub - devbeekei/spring-security-oauth2: spring-security-oauth2
spring-security-oauth2. Contribute to devbeekei/spring-security-oauth2 development by creating an account on GitHub.
github.com
'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 |