build.gradle
의존성
plugins {
id 'org.springframework.boot' version '2.5.1'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'me.jinmin'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
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-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc'
runtimeOnly 'mysql:mysql-connector-java'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
test {
useJUnitPlatform()
}
using setting
application.yml
spring:
profiles:
include: oauth, mysql
application-oauth.yml
spring:
security:
oauth2:
client:
registration:
# naver:
# clientId: [Client ID]
# client-secret: [Secret Key]
# redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
# authorization-grant-type: authorization_code
# scope:
# - email
# - profile_image
# client-name : Naver
google:
clientId: [ClientId]
clientSecret: [ClientSecret]
redirectUri: "{baseUrl}/oauth2/callback/{registrationId}"
scope:
- email
- profile
#
# facebook:
# clientId:
# clientSecret:
# redirectUri:
# scope:
# - email
# - public_profile
#
# github:
# clientId:
# clientSecret:
# redirectUri:
# scope:
# - user:email
# - read:user
provider:
# naver:
# authorization_uri: https://nid.naver.com/oauth2.0/authorize
# token_uri: https://nid.facebook.com/oauth2.0/token
# user_info_uri: https://openapi.naver.com/v1/nid/me
# user_name_attribute: response
facebook:
authorizationUri: https://www.facebook.com/v3.0/dialog/oauth
tokenUri: https://graph.facebook.com/v3.0/oauth/access_token
userInfoUri: https://graph.facebook.com/v3.0/me?fields=id,first_name,middle_name,last_name,name,email,verified,is_verified,picture,width(250),height(250)
app:
auth:
tokenSecret: [Secret Token]
tokenExpirationMsec: 864000000
oauth2:
authorizedRedirectUris:
- http://localhost:8080/oauth2/redirect
logging.level:
org.hibernate.SQL: debug
# org.hibernate.type: trace
.gitignore
에 저장)scope
: 로그인 성공 후 도메인에서 구글에 요청할 사용자 정보 (email
, profile
)redirectUri
: User가 구글에서 인증 성공 후 권한 코드를 전달할 도메인의 endPointapplication-mysql.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/test?allowPublicKeyRetrieval=true&userSSL=false&serverTimezone=UTC&userLegacyDatetimeCode=false
username: root
password: [password]
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
initialization-fail-timeout: 0
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
#show_sql: true
format_sql: true
database-platform: org.hibernate.dialect.MySQL5Dialect
application.yml
설정 바인딩
@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 tokenExpirationMsec;
}
@Getter
@Setter
public static class OAuth2 {
private List<String> authorizedRedirectUris = new ArrayList<>();
}
}
메인 애플리케이션
@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)
public class SecurityJwtOathApplication {
public static void main(String[] args) {
SpringApplication.run(SecurityJwtOathApplication.class, args);
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final long MAX_AGE_SECS = 3600;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
.addMapping("/**") //CORS 적용할 URL 패턴
.allowedOrigins("*") //자원 공유 오리진 지정
.allowedMethods("GET","POST","PUT","PATCH","DELETE","OPTIONS") //요청 허용 메서드
.allowedHeaders("*") //요청 허용 헤더
.allowCredentials(true) //요청 허용 쿠키
.maxAge(MAX_AGE_SECS);
}
}
OAuth 공급자
public enum AuthProvider {
local, facebook, google, github, naver;
}
User
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
private String imageUrl;
private Role role;
@Column(nullable = false)
private Boolean emailVerified = false;
@JsonIgnore
private String password;
@NotNull
@Enumerated(EnumType.STRING)
private AuthProvider provider;
private String providerId;
@Builder
public User(String name, String email, String imageUrl, Role role, Boolean emailVerified,
String password, AuthProvider provider, String providerId) {
this.name = name;
this.email = email;
this.imageUrl = imageUrl;
this.role = role;
this.emailVerified = emailVerified;
this.password = password;
this.provider = provider;
this.providerId = providerId;
}
public User update(String name, String imageUrl) {
this.name = name;
this.imageUrl = imageUrl;
return this;
}
}
Role
@Getter
@RequiredArgsConstructor
public enum Role {
GUEST("ROLE_GUEST", "손님"),
USER("ROLE_USER", "사용자"),
ADMIN("ROLE_ADMIN", "관리자");
private final String key;
private final String title;
}
UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Boolean existsByEmail(String email);
}
configure(HttpSecurity http)
에서 antMacthers
를 통해 설정
@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;
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Bean
public TokenAuthenticationFilter tokenAuthenticationFilter() {
return new TokenAuthenticationFilter();
}
/**
* JWT를 사용하면 Session에 저장하지 않고 Authorization Request를 Based64 encoded cookie에 저장
*/
@Bean
public HttpCookieOAuth2AuthorizationRequestRepository cookieAuthorizationRequestRepository(){
return new HttpCookieOAuth2AuthorizationRequestRepository();
}
@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
/**
* Authrization에 사용할 userDetailsService와 PasswordEncode 정의
*/
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
builder
.userDetailsService(customUserDetailsService)
.passwordEncoder(passwordEncoder());
}
/**
* AhthenticationManager를 외부에서 사용하기 위해 @Bean 설정으로
* Spring Security 밖으로 추출
*/
@Bean(BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors() //cors 허용
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //Session 비활성화
.and()
.csrf().disable() //csrf 비활성화
.formLogin().disable() //로그인폼 비활성화
.httpBasic().disable() //기본 로그인 창 비활성화
.authorizeRequests()
.antMatchers("/").permitAll()
.antMatchers("/api/**").hasAnyRole(Role.GUEST.name(), Role.USER.name(), Role.ADMIN.name())
.antMatchers("/auth/**", "oauth2/**").permitAll()
.anyRequest().authenticated()
.and()
.oauth2Login()
.authorizationEndpoint()
.baseUri("/oauth2/authorization") //클라이언트 첫 로그인 URI
.authorizationRequestRepository(cookieAuthorizationRequestRepository())
.and()
.userInfoEndpoint()
.userService(customOAuth2UserService)
.and()
.successHandler(oAuth2AuthenticationSuccessHandler)
.failureHandler(oAuth2AuthenticationFailureHandler);
http.addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
(참고) 사용자에 대한 인증 처리
UserDetails
, UserDetailsService
SecurityConfig
클래스에 Auth 정의UserDetails
, UserDetailsService
UserDetails : Spring Security에서 User 클래스 역할
UserDetailsService : "에서 UserRepository 역할
UserPrincipal
@Getter
public class UserPrincipal implements OAuth2User, UserDetails {
private Long id;
private String email;
private String password;
private Collection<? extends GrantedAuthority> authorities;
private Map<String, Object> attributes;
public UserPrincipal(Long id, String email, String password,
Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.email = email;
this.password = password;
this.authorities = authorities;
}
public static UserPrincipal create(User user) {
List<GrantedAuthority> authorities =
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
return new UserPrincipal(
user.getId(),
user.getEmail(),
user.getPassword(),
authorities
);
}
public static UserPrincipal create(User user, Map<String, Object> attributes) {
UserPrincipal userPrinciple = UserPrincipal.create(user);
userPrinciple.setAttributes(attributes);
return userPrinciple;
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
public void setAttributes(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public String getName() {
return String.valueOf(id);
}
}
CustomOAuth2UserService
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
try {
return processOAuthUser(userRequest, oAuth2User);
} catch (AuthenticationException e) {
throw e;
} catch (Exception e) {
throw new InternalAuthenticationServiceException(e.getMessage(), e.getCause());
}
}
/**
* 사용자 정보 추출
*/
private OAuth2User processOAuthUser(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory
.getOAuth2UserInfo(userRequest.getClientRegistration().getRegistrationId(), oAuth2User.getAttributes());
if (StringUtils.isEmpty(oAuth2UserInfo.getEmail())) {
throw new OAuth2AuthenticationProcessingException("OAuth2 공급자(구글, 네이버 등)에서 이메일을 찾을 수 없습니다.");
}
Optional<User> userOptional = userRepository.findByEmail(oAuth2UserInfo.getEmail());
User user;
if (userOptional.isPresent()) {
user = userOptional.get();
if (!user.getProvider().equals(AuthProvider.valueOf(userRequest.getClientRegistration().getRegistrationId()))) {
throw new OAuth2AuthenticationProcessingException(user.getProvider() + "계정을 사용하기 위해서 로그인이 필요합니다.");
}
user = updateExistingUser(user, oAuth2UserInfo);
} else {
user = registerNewUser(userRequest, oAuth2UserInfo);
}
return UserPrincipal.create(user);
}
//DB에 없을 때, 등록
private User registerNewUser(OAuth2UserRequest userRequest, OAuth2UserInfo oAuth2UserInfo) {
return userRepository.save(
User.builder()
.name(oAuth2UserInfo.getName())
.email(oAuth2UserInfo.getEmail())
.imageUrl(oAuth2UserInfo.getImageUrl())
.provider(AuthProvider.valueOf(userRequest.getClientRegistration().getRegistrationId()))
.providerId(oAuth2UserInfo.getId())
.build()
);
}
//DB에 없을 때, 수정
private User updateExistingUser(User existingUser, OAuth2UserInfo oAuth2UserInfo) {
return userRepository.save(
existingUser.update(oAuth2UserInfo.getName(), oAuth2UserInfo.getImageUrl()));
}
}
UserPrincipalDetailsService
클래스 역할 = User 정보를 가져오는 역할UserPrincipal
로 변경해 Spring Security로 전달TokenProvider
@Service
public class TokenProvider {
private static final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private AppProperties appProperties;
public TokenProvider(AppProperties appProperties) {
this.appProperties = appProperties;
}
public String createToken(Authentication authentication) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + appProperties.getAuth().getTokenExpirationMsec());
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 authToken) {
try {
Jwts.parser().setSigningKey(appProperties.getAuth().getTokenSecret()).parseClaimsJws(authToken);
return true;
} catch (SignatureException e) {
logger.error("유효하지 않은 JWT 서명");
} catch (MalformedJwtException e) {
logger.error("유효하지 않은 JWT 토큰");
} catch (ExpiredJwtException e) {
logger.error("만료된 JWT 토큰");
} catch (UnsupportedJwtException e) {
logger.error("지원하지 않는 JWT");
} catch (IllegalArgumentException e) {
logger.error("비어있는 JWT");
}
return false;
}
}
TokenAuthenticationFilter
public class TokenAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private TokenProvider tokenProvider;
@Autowired
private CustomUserDetailsService customUserDetailsService;
private static final Logger logger = LoggerFactory.getLogger(TokenAuthenticationFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(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);
}
} catch (Exception e) {
logger.error("Security Context에서 사용자 인증을 설정할 수 없습니다.", e);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7, bearerToken.length());
}
return null;
}
}
UsernamePasswordAuthenticationToken
을 만들어 인증 과정을 거친다.UsernamePasswordAuthenticationToken
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private Object credentials;
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(false);
}
public UsernamePasswordAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true);
}
@Override
public Collection<GrantedAuthority> getAuthorities() {
return super.getAuthorities();
}
@Override
public String getName() {
return super.getName();
}
@Override
public boolean isAuthenticated() {
return super.isAuthenticated();
}
@Override
public void setAuthenticated(boolean authenticated) {
super.setAuthenticated(authenticated);
}
@Override
public Object getDetails() {
return super.getDetails();
}
@Override
public void setDetails(Object details) {
super.setDetails(details);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
}
@Override
public boolean equals(Object obj) {
return super.equals(obj);
}
@Override
public int hashCode() {
return super.hashCode();
}
@Override
public String toString() {
return super.toString();
}
@Override
public boolean implies(Subject subject) {
return false;
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return null;
}
}
setAuthenticated
는 false
setAuthenticated
는 true
@Component
public class HttpCookieOAuth2AuthorizationRequestRepository implements
AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String OAUTH2_AUTHORIZATION_REQUEST_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) {
return CookieUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_NAME)
.map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest,
HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
return;
}
CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_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_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
}
}
STATE
매개 변수 사용을 권장한다.앱
은 인증 요청에 매개변수를 담고, OAuth2 공급자
는 OAuth2 콜백에서 변경되지 않은 해당 매개 변수를 반환상태
와 redirect_uri
를 저장하도록 설정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.setPath("/");
cookie.setValue("");
cookie.setMaxAge(0);
response.addCookie(cookie);
}
}
}
}
public static String serialize(Object obj) {
return Base64.getUrlEncoder()
.encodeToString(SerializationUtils.serialize(obj));
}
public static <T> T deserialize(Cookie cookie, Class<T> t) {
return t.cast(SerializationUtils.deserialize(
Base64.getUrlDecoder().decode(cookie.getValue())
));
}
}
HttpCookieOAuth2AuthorizationRequestRepository
에서 사용될 쿠키의 기능 클래스추상 클래스를 각각의 서비스(Facebook, Google, Guthub 등)에 맞게 구현
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public OAuth2UserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
public Map<String, Object> getAttributes() {
return attributes;
}
public abstract String getId();
public abstract String getName();
public abstract String getEmail();
public abstract String getImageUrl();
}
public class FacebookOAuth2UserInfo extends OAuth2UserInfo {
public FacebookOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("id");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
if (attributes.containsKey("picture")) {
Map<String, Object> pictureObj = (Map<String, Object>) attributes.get("picture");
if (pictureObj.containsKey("data")) {
Map<String, Object> dataObj = (Map<String, Object>) attributes.get("data");
if (dataObj.containsKey("url")) {
return (String) dataObj.get("url");
}
}
}
return null;
}
}
public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("id");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
Github
public class GithubOAuth2UserInfo extends OAuth2UserInfo {
public GithubOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return ((Integer) attributes.get("id")).toString();
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("avatar_url");
}
}
Naver
public class NaverOAuth2UserInfo extends OAuth2UserInfo {
public NaverOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("id");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("profile_image");
}
}
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.facebook.toString())) {
return new FacebookOAuth2UserInfo(attributes);
} else if (registrationId.equalsIgnoreCase(AuthProvider.github.toString())) {
return new GithubOAuth2UserInfo(attributes);
} else if (registrationId.equalsIgnoreCase(AuthProvider.naver.toString())) {
return new NaverOAuth2UserInfo(attributes);
} else {
throw new OAuth2AuthenticationProcessingException(registrationId + " 로그인은 지원하지 않습니다.");
}
}
}
인증에 성공하면 Spring Security는 SercuritConfig에 설정된 성공메서드 호출
@Component
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private TokenProvider tokenProvider;
private AppProperties appProperties;
private HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Autowired
public OAuth2AuthenticationSuccessHandler(TokenProvider tokenProvider,
AppProperties appProperties,
HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository) {
this.tokenProvider = tokenProvider;
this.appProperties = appProperties;
this.httpCookieOAuth2AuthorizationRequestRepository = httpCookieOAuth2AuthorizationRequestRepository;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
String targetUrl = determineTargetUrl(request, response, authentication);
if (response.isCommitted()) {
logger.debug("응답이 이미 커밋되었습니다. " + 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("승인되지 않은 리디렉션 URI가 있어 인증을 진행할 수 없습니다.");
}
String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
String token = tokenProvider.createToken(authentication);
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("token", token)
.build().toString();
}
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;
});
}
}
인증에 실패하면 Spring Security는 SercuritConfig에 설정된 실패메서드 호출
import static me.jinmin.security_jwt_oath.security.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME;
@Component
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Autowired
HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Override
public void onAuthenticationFailure(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String targetUrl = CookieUtils.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue)
.orElse(("/"));
targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("error", exception.getLocalizedMessage())
.build().toUriString();
httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
외부 도메인인 클라이언트가 앱 서버에 자원을 요청하기 위해 CORS를 허용해야한다.
@RequiredArgsConstructor
@RestController
@RequestMapping("/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
@PostMapping("/login")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getEmail(),
loginRequest.getPassword()
)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
String token = tokenProvider.createToken(authentication);
return ResponseEntity.ok(new AuthResponse(token));
}
@PostMapping("/signup")
public ResponseEntity<?> registerUser(@RequestBody SignUpRequest signUpRequest) {
if (userRepository.existsByEmail(signUpRequest.getEmail())) {
throw new BadRequestException("해당 이메일을 이미 사용중입니다.");
}
User result = userRepository.save(User.builder()
.name(signUpRequest.getName())
.email(signUpRequest.getEmail())
.password(passwordEncoder.encode(signUpRequest.getPassword()))
.provider(AuthProvider.local)
.build());
URI location = ServletUriComponentsBuilder
.fromCurrentContextPath().path("/user/me")
.buildAndExpand(result.getId()).toUri();
return ResponseEntity.created(location)
.body(new ApiResponse(true, "성공적으로 계정이 생성됐습니다."));
}
}
@CurrentUser
: 인증 된 사용자 주체를 컨트롤러에 삽입
@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal
public @interface CurrentUser {
}
UserController
@RequiredArgsConstructor
@RestController
public class UserController {
private final UserRepository userRepository;
@GetMapping("/user/me")
@PreAuthorize("hasRole('USER')")
public User getCurrentUser(@CurrentUser UserPrincipal userPrincipal) {
return userRepository.findById(userPrincipal.getId())
.orElseThrow(() -> new ResourceNotFoundException("User", "id", userPrincipal.getId()));
}
}
DTO
LoginRequest
@Getter
public class LoginRequest {
@NotBlank
@Email
private String email;
@NotBlank
private String password;
@Builder
public LoginRequest(String email, String password) {
this.email = email;
this.password = password;
}
}
SignUpRequest
@Getter
public class SignUpRequest {
private String name;
private String email;
private String password;
@Builder
public SignUpRequest(String name, String email, String password) {
this.name = name;
this.email = email;
this.password = password;
}
}
AuthResponse
@Getter
public class AuthResponse {
private String accessToken;
private String tokenType = "Bearer";
@Builder
public AuthResponse(String accessToken) {
this.accessToken = accessToken;
}
}
ApiResponse
@Getter
public class ApiResponse {
private boolean success;
private String message;
@Builder
public ApiResponse(boolean success, String message) {
this.success = success;
this.message = message;
}
}
BadRequestException
: RuntimeException
상속
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
public BadRequestException(String message, Throwable throwable) {
super(message, throwable);
}
}
ResourceNotFoundException
: RuntimeException
상속
@Getter
@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;
}
}
OAuth2AuthenticationProcessingException
: AuthenticationException
상속
public class OAuth2AuthenticationProcessingException extends AuthenticationException {
public OAuth2AuthenticationProcessingException(String msg, Throwable throwable) {
super(msg, throwable);
}
public OAuth2AuthenticationProcessingException(String msg) {
super(msg);
}
}
SecurityConfig
의 configure
메서드를 통해 클라이언트 첫 로그인 URI로 이동하면 로그인 가능ClientId
및 ClientSecretKey
형성 : https://medipress.co.kr/archives/2147