Springboot Oauth2 & jwt & Kakao & Android

윤승환·2022년 8월 9일
1

어떤 client와 작업할지가 가장 중요하다.

만약 RESTAPI로 작업을 하는경우(WEBservice)에는 OAuth2의 라이브러리를 server단에서 처리하는 로직이 가장 깔끔하다

만약 Android등의 application과 작업하는 경우에는 Library를 import할 필요가 없다. 이 경우는 KakaoDeveloper의 RESTAPI공식문서를 참조하여 Client단에서 Token을 받아서 서비스하는 것이 합당하다.


  • WEBservice

    웹서비스는 우선, OAuth2의 client로 kakao를 등록해줘야 하는 작업이 필요함 -> 왜냐면 기존 library에는 google, facebook, twitter등의 글로벌 기업만 표준으로 잡혀 있기에.. 표준을 따른 Oauth2 client를 등록해줘야 한다.

  • Library설치(build.gradle) 추후 RESTAPI호출 및 JWT생성

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
//katalk access token을 가져오기 위한 Oauth 서버와의 통신을 위한 webclient
implementation 'org.springframework.boot:spring-boot-starter-webflux'
//jwt
implementation 'io.jsonwebtoken:jjwt:0.9.1'
  • application.properties에 등록
#REST API KEY
spring.security.oauth2.client.registration.kakao.client-id=<REST API KEY>
#authorization code ?? ?? uri
spring.security.oauth2.client.registration.kakao.redirect-uri=<REDIRECT URI>
spring.security.oauth2.client.registration.kakao.client-authentication-method=POST
#spring.security.oauth2.client.registration.kakao.client-secret=secret-key //카카오는 존재하지 않음
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
#spring.security.oauth2.client.registration.kakao.scope=profile_nickname, profile_image, account_email, gender, birthday
spring.security.oauth2.client.registration.kakao.scope=profile_nickname

spring.security.oauth2.client.registration.kakao.client_name=kakao
spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
spring.security.oauth2.client.provider.kakao.user-name-attribute=id
  • 이후, 해당 값들을 OAuth2라이브러리에 등록해줘야 한다.
    해당 파일은 config > OAuth2ClientRegistrationRepositoryConfiguration
@Configuration
@EnableConfigurationProperties(OAuth2ClientProperties.class)
@Conditional(ClientsConfiguredCondition.class)
@RequiredArgsConstructor
public class OAuth2ClientRegistrationRepositoryConfiguration {

    private final OAuth2ClientProperties properties;

    @Bean
    @ConditionalOnMissingBean(ClientsConfiguredCondition.class)
    public InMemoryClientRegistrationRepository clientRegistrationRepository(){
        List<ClientRegistration> registrations = new ArrayList<>(
                OAuth2ClientPropertiesRegistrationAdapter.getClientRegistrations(this.properties).values());
        return new InMemoryClientRegistrationRepository(registrations);
    }

}
  • Jwt생성
@Component
@Slf4j
public class JwtTokenProvider {
    @Value("${jwt.access-token.expire-length}")
    private long accessTokenValidityInMilliseconds;
    @Value("${jwt.refresh-token.expire-length}")
    private long refreshTokenValidityInMilliseconds;
    @Value("${jwt.token.secret-key}")
    private String secretKey;

    public String createAccessToken(String payload){
        return createToken(payload, accessTokenValidityInMilliseconds);
    }

    public String createRefreshToken(){
        byte[] array = new byte[7];
        new Random().nextBytes(array);
        String generatedString = new String(array, StandardCharsets.UTF_8);
        return createToken(generatedString, refreshTokenValidityInMilliseconds);
    }

    public String createToken(String payload, long expireLength){
        Claims claims = Jwts.claims().setSubject(payload);
        Date now = new Date();
        Date validity = new Date(now.getTime() + expireLength);
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(SignatureAlgorithm.HS512,secretKey)
                .compact();
    }

    public String getPayload(String token){
        try{
            return Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token)
                    .getBody()
                    .getSubject();
        }catch (ExpiredJwtException e){
            return e.getClaims().getSubject();
        }catch (JwtException e){
            throw new RuntimeException("유효하지 않은 토큰입니다.");
        }
    }

    public boolean validateToken(String token){
        try{
            Jws<Claims> claimsJws = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token);
            return !claimsJws.getBody().getExpiration().before(new Date());
        }catch (JwtException | IllegalArgumentException exception){
            return false;
        }
    }

}
  • Oauth2UserInfo 생성 : Oauth2로 받아오는 정보에 대한 기본 뼈대라고 생각
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 Long getId();
    public abstract String getName();
    public abstract String getNickName();
}
  • KakaoUserInfo로 구체화
public class KakaoUserInfo extends Oauth2UserInfo {
    public KakaoUserInfo(Map<String, Object> attributes) {
        super(attributes);
    }
    @Override
    public Long getId() {
        return Long.parseLong(String.valueOf(attributes.get("id")));
    }

    @Override
    public String getName() {
        return (String) getKakaoAccount().get("name");
    }

    @Override
    public String getNickName() {
        return (String) getProfile().get("nickname");
    }
    public Map<String, Object> getKakaoAccount(){
        return(Map<String, Object>) attributes.get("kakao_account");
    }

    public Map<String, Object> getProfile(){
        return (Map<String, Object>) getKakaoAccount().get("profile");
    }

    public String getProvider(){
        return "kakao";
    }

}
  • Entity(USER)
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "RTUSER")
@Getter
public class User extends BaseEntity{
    @Id
    @Column(name = "user_id")
    private Long id; //katalk PK 값으로 매핑하기
    private String name;
    private String nickName;
    private String kakaoAccessToken;
    private String kakaoRefreshToken;
    private String cloudEmail;
    private String calenderEmail;
    @Enumerated(EnumType.STRING)
    private Role role;

    @Column(name = "kakao_update")
    private LocalDateTime kakaoUpdate;
    @OneToMany(mappedBy = "user")
    private List<Calender> calenderList = new ArrayList<>();

    public User(Long id, String name, String nickName, String kakaoAccessToken, String kakaoRefreshToken, Role role){
        this.id = id;
        this.name = name;
        this.nickName = nickName;
        this.kakaoAccessToken = kakaoAccessToken;
        this.kakaoRefreshToken = kakaoRefreshToken;
        this.role = role;
    }


    public void setKakaoUpdate(){
        this.kakaoUpdate = LocalDateTime.now();
    }
    public void updateNickName(String nickName){
        this.nickName = nickName;
    }
    public void setKakaoToken(String kakaoAccessToken, String kakaoRefreshToken){
        this.kakaoAccessToken = kakaoAccessToken;
        this.kakaoRefreshToken = kakaoRefreshToken;
    }


}
  • DTO(loginResponse)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class LoginResponse {
    private Long id;
    private String name;
    private String nickName;
    private Role role;
    private String accessToken;
    private String refreshToken;

    public LoginResponse(User user, String accessToken, String refreshToken){
        this.id = user.getId();
        this.name = user.getName();
        this.nickName = user.getNickName();
        this.role = user.getRole();
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;

    }
}
  • OauthService 생성
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class OauthService {
    private static final String BEARER_TYPE = "Bearer";
    private final InMemoryClientRegistrationRepository inMemoryRepository;
    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;

    /**
     * InMemoryRepo : oauth properties를 담고 있음.
     *
     * @getToken() 넘겨받은 code로 access토큰 요청
     * @getUserProfile 첫 로그인 시 회원가입 진행
     */
    @Transactional
    public LoginResponse login(String providerName, String code) {
        ClientRegistration provider = inMemoryRepository.findByRegistrationId(providerName);
        OAuth2AccessTokenResponse tokenResponse = getToken(code, provider);
        tokenResponse.getAccessToken(); // accesstoken
        tokenResponse.getRefreshToken(); // refreshtoken
        User user = getUserProfile(providerName, tokenResponse, provider);
        String accessToken = jwtTokenProvider.createAccessToken(String.valueOf(user.getId()));
        String refreshToken = jwtTokenProvider.createRefreshToken();

        return new LoginResponse(user, accessToken, refreshToken);
    }


    private OAuth2AccessTokenResponse getToken(String code, ClientRegistration provider) {
        return WebClient.create()
                .post()
                .uri(provider.getProviderDetails().getTokenUri())
                .headers(httpHeaders -> {
                    httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
                    httpHeaders.setAcceptCharset(Collections.singletonList(StandardCharsets.UTF_8));
                })
                .bodyValue(tokenRequest(code, provider))
                .retrieve()
                .bodyToMono(OAuth2AccessTokenResponse.class)
                .block();
    }

    private MultiValueMap<String, String> tokenRequest(String code, ClientRegistration provider) {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("code", code);
        formData.add("grant_type", "authorization_code");
        formData.add("redirect_uri", provider.getRedirectUri());
        formData.add("client_id", provider.getClientId());
        return formData;
    }

    private User getUserProfile(String providerName, OAuth2AccessTokenResponse tokenResponse, ClientRegistration provider) {
        Map<String, Object> userAttributes = getUserAttributes(provider, tokenResponse);
        //뭘가져올 수 있는지 알아야함.
        // id -> get('id')
        // nickname -> get('kakao_account) -> get("profile") -> get("nickname")
        // name -> get("kakao_account) -> get("name")
        if (providerName.equals("kakao")) {
            throw new IllegalArgumentException("잘못된 접근입니다.");
        }
        KakaoUserInfo kakaoUserInfo = new KakaoUserInfo(userAttributes);

        Long kakao_id = kakaoUserInfo.getId();
        String provide = kakaoUserInfo.getProvider();
        String name = kakaoUserInfo.getName();
        String nickName = kakaoUserInfo.getNickName();

        if (userRepository.findById(kakao_id).isPresent()) {
            throw new DuplicateSignInException();
        }
        User user = new User(kakao_id, name, nickName, tokenResponse.getAccessToken().getTokenValue(), tokenResponse.getRefreshToken().getTokenValue(), Role.ROLE_USER);
        User save = userRepository.save(user);

        return save;
    }

    private Map<String, Object> getUserAttributes(ClientRegistration provider, OAuth2AccessTokenResponse tokenResponse) {
        return WebClient.create()
                .get()
                .uri(provider.getProviderDetails().getUserInfoEndpoint().getUri())
                .headers(httpHeaders -> httpHeaders.setBearerAuth(tokenResponse.getAccessToken().getTokenValue()))
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
                })
                .block();
    }

}
  • controller
@RequiredArgsConstructor
@Slf4j
@RestController
public class TestController {
    private final OauthService oauthService;

    @GetMapping("/login/oauth/{provider}")
    public ResponseEntity<LoginResponse> login(@PathVariable String provider, @RequestParam String code) {
        LoginResponse loginResponse = oauthService.login(provider, code);
        return ResponseEntity.ok().body(loginResponse);
    }
}

Android와 통신하기 feat RESTAPI로 호출

  • libary필요없음.

  • 서비스 단만 바꾸면 된다(Oauth2와 의존성 있는 모든 것을 지우고)

@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class OauthService {
    private static final String BEARER_TYPE = "Bearer";
    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;

    @Transactional
    public LoginResponse loginWithToken(String providerName, UserToken userToken) {
        User user = getUserProfileByToken(providerName, userToken);
        String accessToken = jwtTokenProvider.createAccessToken(String.valueOf(user.getId()));
        String refreshToken = jwtTokenProvider.createRefreshToken();
        return new LoginResponse(user, accessToken, refreshToken);
    }

    private Map<String, Object> getUserAttributesByToken(UserToken userToken){
        return WebClient.create()
                .get()
                .uri("https://kapi.kakao.com/v2/user/me")
                .headers(httpHeaders -> httpHeaders.setBearerAuth(userToken.getAccessToken()))
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
                .block();
    }

    private User getUserProfileByToken(String providerName, UserToken userToken){
        if(!providerName.equals("kakao")){
            throw new IllegalArgumentException("잘못된 접근입니다.");
        }
        Map<String, Object> userAttributesByToken = getUserAttributesByToken(userToken);
        KakaoUserInfo kakaoUserInfo = new KakaoUserInfo(userAttributesByToken);
        Long kakao_id = kakaoUserInfo.getId();
        String name = kakaoUserInfo.getName();
        String nickName = kakaoUserInfo.getNickName();
        if(userRepository.findById(kakao_id).isPresent()){
            throw new DuplicateSignInException();
        }
        User user = new User(kakao_id, name, nickName, userToken.getAccessToken(), userToken.getRefreshToken(), Role.ROLE_USER);
        User save = userRepository.save(user);
        return save;
    }

}

0개의 댓글