💡 카카오는 유저정보 제한이 심하여 보류하였다.
Resource Owner | 리소스 소유자 또는 사용자. 보호된 자원에 접근할 수 있는 자격을 부여해 주는 주체. OAuth2 프로토콜 흐름에서 클라이언트를 인증(Authorize)하는 역할을 수행한다. 인증이 완료되면 권한 획득 자격(Authorization Grant)을 클라이언트에게 부여한다. 개념적으로는 리소스 소유자가 자격을 부여하는 것이지만 일반적으로 권한 서버가 리소스 소유자와 클라이언트 사이에서 중개 역할을 수행하게 된다. |
---|---|
Client | 보호된 자원을 사용하려고 접근 요청을 하는 애플리케이션이다. |
Resource Server | 사용자의 보호된 자원을 호스팅하는 서버이다. |
Authorization Server | 권한 서버. 인증/인가를 수행하는 서버로 클라이언트의 접근 자격을 확인하고 Access Token을 발급하여 권한을 부여하는 역할을 수행한다. |
https://blog.naver.com/mds_datasecurity/222182943542
OAuth2AuthenticationSuccessHandler
62줄, 일단 다 ROLE_USER로 등록CustomOAuth2Service
에서 UserProfile userProfile = OAuthAttributes.*extract*(registrationId, oAuth2User);
에 attribute만 넘겨도 되도록 리팩토링 필요localhost:8080/oauth2/authorization/google
localhost:8080/oauth2/authorization/naver
├── configuration
│ └── SecurityConfiguration.java
├── domain
│ └── User.java
├── dto
│ ├── Response.java
│ └── user
│ ├── OAuthAttributes.java
│ ├── UserProfile.java
│ ├── UserLoginResponse.java
│ └── UserRole.java
├── repository
│ └── UserRepository.java
├── security
│ ├── CookieAuthorizationRequestRepository.java
│ ├── CustomOAuth2Service.java
│ ├── JwtExceptionFilter.java
│ ├── JwtFilter.java
│ └── OAuth2AuthenticationSuccessHandler.java
├── service
│ ├── UserDetailsServiceImpl.java
│ └── UserService.java
└── util
├── CookieUtil.java
└── JwtUtil.java
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client:3.0.1'
spring:
security:
oauth2:
client:
registration:
google:
client-id: 482160302560-b4fpna955hs5mgjjpns9rsf12ffva64u.apps.googleusercontent.com
client-secret: 비밀키
scope: profile,email
# 네이버는 spring security가 기본적을 제공해주지 않기 때문에 github, google과 달리 많은 정보를 적어줘야한다.
naver:
client-id: 4mcH9WQY8HRWaiNa7LM6
client-secret: 비밀키
redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
authorization_grant_type: authorization_code
scope: name,email
client-name: Naver
kakao: #app-user-id, access-token, refresh-token
client-id: 29ed7329b4fd1d7203e6c6315a07ef22
client-secret: 비밀키
scope: profile_nickname,account_email
redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
authorization-grant-type: authorization_code
client-name: kakao
client-authentication-method: POST
provider:
naver:
authorization_uri: https://nid.naver.com/oauth2.0/authorize
token_uri: https://nid.naver.com/oauth2.0/token
user-info-uri: https://openapi.naver.com/v1/nid/me
user_name_attribute: response
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
#Encoding
server:
servlet:
encoding:
force-response: true
#JWT token
jwt:
token:
secret: key
refresh: "refresh"
#redirect Uri
app:
oauth2:
authorizedRedirectUri: "http://localhost:3000/oauth2/redirect"
http://localhost:3000
으로 부터의 요청을 허용한다@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtUtil jwtUtil;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final CustomOAuth2Service customOAuth2Service;
private final JwtExceptionFilter jwtExceptionFilter;
private final OAuth2AuthenticationSuccessHandler OAuth2AuthenticationSuccessHandler;
private final String[] SWAGGER = {
"/v3/api-docs",
"/swagger-resources/**", "/configuration/security", "/webjars/**",
"/swagger-ui.html", "/swagger/**", "/swagger-ui/**"};
private final String[] UI = {
"/api/v1/hello/**", "/css/**", "/img/**", "/static/**", "/resources/**", "/", "/index"};
private final String[] AUTHORIZATION = {
"/api/v1/users/join", "/api/v1/users/login","/api/v1/users/exception",
"/oauth2/authorization/**"};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.httpBasic().disable()
.csrf().disable()
.cors().and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(UI).permitAll()
.antMatchers(SWAGGER).permitAll()
.antMatchers(AUTHORIZATION).permitAll()
.antMatchers("/api/v1/posts/my").authenticated()
.antMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
.antMatchers("/api/v1/users/*/role/change").access("hasRole('ADMIN')")
.antMatchers("/api/**").authenticated()
.anyRequest().hasRole("ADMIN")
.and()
.exceptionHandling().accessDeniedHandler(customAccessDeniedHandler)
.and()
.exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint)
.and()
.oauth2Login()
.userInfoEndpoint().userService(customOAuth2Service) //provider로부터 획득한 유저정보를 다룰 service단을 지정한다.
.and()
.successHandler(OAuth2AuthenticationSuccessHandler) //OAuth2 로그인 성공 시 호출한 handler
// .failureHandler(authenticationFailureHandelr)
.and()
.addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, JwtFilter.class)
.build();
}
@Bean
public WebMvcConfigurer corsConfigurer(){
return new WebMvcConfigurer() {
private final long MAX_AGE_SECS = 3600;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(MAX_AGE_SECS);
}
};
}
}
OAuth에서 가져온 값들을 저장할 class를 생성한다.
Resource server의 Authentication에서 가져온 정보를 담기 위해 OAuth2User를 구현하였다.
@AllArgsConstructor
@Getter
@Builder
public class UserProfile implements OAuth2User{ //Resource Server마다 제공하는 정보가 다르므로 통일시키기 위한 profile
private String userName; //authentication의 name
private Collection<? extends GrantedAuthority> authorities;
private Map<String, Object> attributes; // oauthId, name, email
public User toUser(){
return User.builder()
.oauthId((String) this.attributes.get("oauthId"))
.userName(this.userName)
.email((String) this.attributes.get("email"))
.name((String) this.attributes.get("name"))
.role(UserRole.ROLE_USER)
.build();
}
@Override
public Map<String, Object> getAttributes() {
return this.attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
@Override
public String getName() {
return this.userName;
}
}
OAuth에 따라 attribute을 꺼내어 UserProfile에 저장하기 위한 enum을 생성한다.
@AllArgsConstructor
public enum OAuthAttributes { //OAuth 서비스에 따라 얻어온 유저 정보의 key값이 다르기 때문에 각각 관리한다.
GOOGLE("google", (oAuth2User) -> {
return UserProfile.builder()
.userName(oAuth2User.getName())
.authorities(oAuth2User.getAuthorities())
.attributes(
Map.of(
"oauthId", String.valueOf(oAuth2User.getAttributes().get("sub")),
"name", (String) oAuth2User.getAttributes().get("name"),
"email", (String) oAuth2User.getAttributes().get("email")
)
)
.build();
}),
NAVER("naver", (oAuth2User) -> {
Map<String, String> attributes = oAuth2User.getAttribute("response");
return UserProfile.builder()
.userName(attributes.get("id"))
.authorities(oAuth2User.getAuthorities())
.attributes(
Map.of(
"oauthId", attributes.get("id"),
"name", attributes.get("name"),
"email", attributes.get("email")
)
)
.build();
});
private final String registrationId;
private final Function<OAuth2User, UserProfile> setUserInfo;
//OAuth 서비스의 정보를 통해 UserProfile을 얻는다.
public static UserProfile extract(String registrationId, OAuth2User oAuth2User){
return Arrays.stream(values())
.filter(provider -> registrationId.equals(provider.registrationId))
.findFirst()
.orElseThrow(IllegalArgumentException::new)
.setUserInfo.apply(oAuth2User);
}
}
🚫 dependency 적용이 안 된다!
https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-oauth2-client
2.7.5로 버전을 낮추니 패키지를 이용할 수 있었다.
좀 더 근본적인 원인을 찾아서 공식 문서를 보았다. → 더 찾아봐야할 듯 일단 버전 낮춰서 진행
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOAuth2Service implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest); //OAuth 서비스(google..)에서 가져온 유저 정보
log.info("oAuth2User : {}", oAuth2User.toString());
Map<String, Object> attributes = oAuth2User.getAttributes(); //유저 정보 Map에 담음
log.info("attribue : {}", attributes.toString());
String registrationId = userRequest.getClientRegistration().getRegistrationId(); //사용한 OAuth 서비스 이름
//OAuth 서비스에 따라 유저정보를 공통된 class인 UserProfile 객체로 만들어 준다.
UserProfile userProfile = OAuthAttributes.extract(registrationId, oAuth2User); /**attribute만 넘기도록 리팩토링 필요**/
User user = saveOrUpdate(userProfile); //DB에 저장
log.info("userName : {}", oAuth2User.getName());
return userProfile;
}
private User saveOrUpdate(UserProfile userProfile){
String userName = userProfile.getUserName();
User user = userRepository.findByUserName(userName)
.map(m -> m.update(userName, (String) userProfile.getAttributes().get("email"))) //OAuth 서비스 유저정보 변경이 있으면 업데이트
.orElse(userProfile.toUser()); //user가 없으면 새로운 user 생성
return userRepository.save(user);
}
}
⌨️ CookieUtil
• 쿠키를 생성, 제거, 직렬화, 역직렬화 하는 클래스
public class CookieUtil {
public static Optional<Cookie> getCookie(HttpServletRequest request, String name){
Cookie[] cookies = request.getCookies();;
if(cookies != null && cookies.length > 0){
return Arrays.stream(cookies).distinct()
.filter(cookie -> cookie.getName().equals(name))
.findFirst();
}
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())));
}
}
⌨️ CookieAuthorizationRequestRepository
• Provider와의 Authorization 과정에서 Authorization request
를 cookie에 저장하기 위한 클래스
successHandler
와 failureHandler
에서 제거된다@Component
public class CookieAuthorizationRequestRepository implements AuthorizationRequestRepository {
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 COOKIE_EXPIRE_SECONDS = 180;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
return CookieUtil.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie -> CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if(authorizationRequest == null){
removeAuthorizationRequestCookies(request, response);
}
CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME,
CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if(StringUtils.isNotBlank(redirectUriAfterLogin)){
CookieUtil.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS);
}
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response){
CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtil.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
return this.loadAuthorizationRequest(request);
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
return this.loadAuthorizationRequest(request);
}
}
@Component
@Slf4j
@RequiredArgsConstructor
public class JwtUtil {
@Value("${jwt.token.secret}")
private final String secretKey;
@Value("${jwt.token.refresh}")
private final String refreshKey;
private final UserRepository userRepository;
private final long accessExpiredTimeMs = 1000 * 60 * 60; // 60min
private final long refreshExpirredTimeMs = 1000 * 60 * 60 * 24 * 7; // 일주일
//key를 만드는 메서드
private Key makeKey(){
return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
//token 생성하는 메서드
public String generateToken(String userName, UserRole role){
/**...**/
}
public void generateRefreshToken(Authentication authentication, HttpServletResponse response){
String refreshToken = Jwts.builder()
.signWith(makeKey())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + refreshExpirredTimeMs))
.compact();
saveRefreshToken(authentication, refreshToken); //refreshToken DB에 저장
ResponseCookie cookie = ResponseCookie.from(refreshKey, refreshToken)
.httpOnly(true)
.secure(true)
.sameSite("Lax")
.maxAge(refreshExpirredTimeMs/1000)
.path("/")
.build();
response.addHeader("Set-Cookie", cookie.toString());
}
private void saveRefreshToken(Authentication authentication, String refreshToken) {
UserProfile user = (UserProfile) authentication.getPrincipal();
String userName = user.getUserName();
userRepository.updateRefreshToken(userName, refreshToken);
}
//token에서 claim 추출하는 메서드
//token으로 authentication 꺼내는 메서드
authorizedRedirectUri
로 client에게 전송한다.JwtUtils, testcode, controller가 전반적으로 변경되었다..
{
"Name": [113419156514707185505],
"Granted Authorities": [[ROLE_USER, SCOPE_https://www.googleapis.com/auth/userinfo.email, SCOPE_https://www.googleapis.com/auth/userinfo.profile, SCOPE_openid]],
"User Attributes": [{sub=113419156514707185505, name=조예지, given_name=예지, family_name=조, picture=https://lh3.googleusercontent.com/a/AEdFTp4WdAgnPNep26FCrHdQSxxlg-kSOzWAL0K8K7E=s96-c, [email=whdpwl2@gmail.com](mailto:email=whdpwl2@gmail.com), email_verified=true, locale=ko}]
}
{
"Name": [{id=-ZJQm9WRI3PcvLa23nY-Mc2BsUp2TJCpHeFCqftCBaY, email=whdpwl2@gmail.com, name=조예지}],
"Granted Authorities": [[ROLE_USER]],
"User Attributes": [{resultcode=00, message=success, response={id=-ZJQm9WRI3PcvLa23nY-Mc2BsUp2TJCpHeFCqftCBaY, email=whdpwl2@gmail.com, name=조예지}}]
}
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Value("${app.oauth2.authorizedRedirectUri}")
private String redirectUri;
private final JwtUtil jwtUtil;
private final CookieAuthorizationRequestRepository authorizationRequestRepository;
/**주석들은 redirect 구현 시 사용예정**/
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
clearAuthenticationAttributes(request, response);
writeTokenResponse(response, authentication);
}
private void writeTokenResponse(HttpServletResponse response, Authentication authentication) throws IOException {
//JWT 생성
UserProfile user = (UserProfile) authentication.getPrincipal();
String userName = user.getName();
UserRole role = UserRole.ROLE_USER; //일단 다 user로 설정
String accessToken = jwtUtil.generateToken(userName, role);
jwtUtil.generateRefreshToken(authentication, response);
ObjectMapper objectMapper = new ObjectMapper();
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(),
new UserLoginResponse(accessToken));
}
private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
authorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
}
}
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User extends BaseEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String password;
@Column(unique = true, nullable = false)
private String userName;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private UserRole role;
/**OAuth2 적용**/
private String oauthId;
private String name;
private String email;
private String introduction;
private String refreshToken;
public User update(String userName, String email){
this.userName = userName;
this.email = email;
return this;
}
public static String getUserNameFromAuthentication(Authentication authentication){
UserDetails user = (UserDetails) authentication.getPrincipal();
return user.getUsername();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(this.getRole().name()));
}
@Override
public String getUsername() {
return this.userName;
}
@Override
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
public boolean isAccountNonExpired() {
return true;
}
@Override
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
public boolean isAccountNonLocked() {
return true;
}
@Override
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
public boolean isCredentialsNonExpired() {
return true;
}
@Override
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
public boolean isEnabled() {
return true;
}
}
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUserName(username)
.orElseThrow(() -> new UserException(ErrorCode.USERNAME_NOT_FOUND));
}
}
참고:
Using OAuth 2.0 for Web Server Applications | Authorization | Google Developers
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api
https://velog.io/@max9106/OAuth
https://kingofbackend.tistory.com/227
https://alkhwa-113.tistory.com/41
https://cme10575.tistory.com/168
https://ozofweird.tistory.com/586
https://velog.io/@tmdgh0221/Spring-Security-와-OAuth-2.0-와-JWT-의-콜라보
https://europani.github.io/spring/2022/01/15/036-oauth2-jwt.html
https://zzznara2.tistory.com/687
→ 필요 없어졌음
OAuth2User에서 name을 가져오기 위해 쓸려고 했으나 Attributes를 이용해서 해결되었다.
OAuth2User의 name은 String이고, attributes는 Map이라 훨씬 가져오기가 편리하였다.