Bibi's DevLog ๐Ÿค“๐ŸŽ

[Spring] ๊ตฌ๊ธ€ OAuth ๊ตฌํ˜„ํ•˜๊ธฐ (+JWT) ๋ณธ๋ฌธ

๐Ÿ–ฅ BE ๋ฐฑ์—”๋“œ/Spring ์Šคํ”„๋ง

[Spring] ๊ตฌ๊ธ€ OAuth ๊ตฌํ˜„ํ•˜๊ธฐ (+JWT)

๋น„๋น„ bibi 2021. 6. 10. 00:01

[Spring] ๊ตฌ๊ธ€ OAuth ๊ตฌํ˜„ํ•˜๊ธฐ (+JWT)

์ถœ์ฒ˜

https://preamtree.tistory.com/167

https://withseungryu.tistory.com/116

๊ตฌ๊ธ€ ๊ณต์‹๋ฌธ์„œ - OAuth2.0์œผ๋กœ ๊ตฌ๊ธ€API ์•ก์„ธ์Šคํ•˜๊ธฐ

์ „์ฒด flow

  1. /login์œผ๋กœ ์ ‘์† ์‹œ ๊ตฌ๊ธ€ OAuth2.0์„ ์ด์šฉํ•ด ์‚ฌ์šฉ์ž ์ •๋ณด(์Šน์ธ)์„ ์š”์ฒญํ•จ. ์Šน์ธ๋˜๋ฉด ๋ฆฌ๋””๋ ‰์…˜URI๋กœ ์ด๋™. ์ด ๋•Œ ๊ตฌ๊ธ€๋กœ๋ถ€ํ„ฐ code๋ฅผ ๋ฐœ๊ธ‰๋ฐ›์Œ.
  2. ๊ตฌ๊ธ€ API ์ฝ˜์†” - API ๋ฐ ์„œ๋น„์Šค - ์‚ฌ์šฉ์ž ์ธ์ฆ ์ •๋ณด - ์‚ฌ์šฉ์ž ์ธ์ฆ ์ •๋ณด ๋งŒ๋“ค๊ธฐ - OAuth ํด๋ผ์ด์–ธํŠธ ID - ์•ฑ ์œ ํ˜• ์„ ํƒ - ์ด๋ฆ„ ์„ค์ • - ์Šน์ธ๋œ ๋ฆฌ๋””๋ ‰์…˜ URI ์ง€์ •. โ— ์ด ๋•Œ ๋ฐœ๊ธ‰๋˜๋Š” ํด๋ผ์ด์–ธํŠธID(Client Id), ํด๋ผ์ด์–ธํŠธ ๋ณด์•ˆ๋น„๋ฐ€(Client Secret)์„ ์ž˜ ์ €์žฅํ•ด ๋‘์–ด์•ผ ํ•˜๋ฉฐ, ๋…ธ์ถœ๋˜์–ด์„  ์•ˆ ๋จ.
    ๋ฆฌ๋””๋ ‰์…˜ URI = redirect url. ๊ตฌ๊ธ€ ์ธ์ฆ ํ›„ ๋ฆฌ๋‹ค์ด๋ ‰์…˜๋  ์ฃผ์†Œ๋ฅผ ์ž…๋ ฅํ•˜๋ฉด ๋จ
  3. ๋ฆฌ๋””๋ ‰์…˜๋œ ํŽ˜์ด์ง€์—์„œ code์™€ Client Id, Client Secret ๋“ฑ์„ ์ด์šฉํ•ด POST์š”์ฒญ์„ ๋งŒ๋“ค์–ด ์ „์†กํ•˜๋ฉฐ, ์ด ๊ฒฐ๊ณผ๊ฐ’์„ ResponseEntity๋กœ ๋ฐ›์Œ. ๊ทธ๋ฆฌ๊ณ  ResponseEntity์˜ body๋กœ๋ถ€ํ„ฐ OAuth ์•ก์„ธ์Šคํ† ํฐ์ด ์žˆ๋Š”์ง€ ํ™•์ธ ํ›„ ๊ฐ€์ ธ์˜ดํ•„์š”์— ๋”ฐ๋ผ ์œ ์ €์˜ ์•ก์„ธ์Šคํ† ํฐ์„ DB์— ์ผ์‹œ์ ์œผ๋กœ ์ €์žฅ
  4. ๊ตฌ๊ธ€ ์œ ์ € ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๊ธฐ ์œ„ํ•ด, ์•ก์„ธ์Šคํ† ํฐ์„ ์ด์šฉํ•ด GET์š”์ฒญ์„ ๋งŒ๋“ค์–ด ๋ณด๋ƒ„. Http Request ํ—ค๋”์— ์•ก์„ธ์Šคํ† ํฐ์„ ๋„ฃ์–ด ์š”์ฒญํ•˜๋ฉฐ, ์ด ๊ฒฐ๊ณผ๊ฐ’์„ ResponseEntity๋กœ ๋ฐ›์Œ. ๊ทธ๋ฆฌ๊ณ  ResponseEntity์˜ body๋กœ๋ถ€ํ„ฐ ์œ ์ € ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ด
  5. ๋งŒ์•ฝ ํ•ด๋‹น ์œ ์ €๊ฐ€ ๊ฐ€์ž…๋œ ์  ์—†๋Š” ์œ ์ €๋ผ๋ฉด - ์•ก์„ธ์Šคํ† ํฐ์„ ๋‹ด์•„ ํ•ด๋‹น ์œ ์ €๋ฅผ ํšŒ์›๊ฐ€์ž…์‹œํ‚ด / ์ด๋ฏธ ๊ฐ€์ž…๋œ ์  ์žˆ๋Š” ์œ ์ €๋ผ๋ฉด - ๋กœ๊ทธ์ธ(= ์œ ์ € ์ •๋ณด๊ฐ€ ๋‹ด๊ธด JWTํ† ํฐ ๋ฐœ๊ธ‰)
  6. ๋ฆฌ๋””๋ ‰์…˜URI์— ๋Œ€ํ•œ ์š”์ฒญ์˜ ์‘๋‹ต์œผ๋กœ JWTํ† ํฐ์„ ๋‹ด์•„ ๋ฐ˜ํ™˜
 

Google Cloud Platform

ํ•˜๋‚˜์˜ ๊ณ„์ •์œผ๋กœ ๋ชจ๋“  Google ์„œ๋น„์Šค๋ฅผ Google Cloud Platform์„ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ๋กœ๊ทธ์ธํ•˜์„ธ์š”.

accounts.google.com

build.gradle

์˜์กด์„ฑ์— implementation 'io.jsonwebtoken:jjwt:0.9.1'๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•จ
์•„๋ž˜๋Š” ๋‚ด๊ฐ€ ์ถ”๊ฐ€ํ•œ ์˜์กด์„ฑ ์‚ฌํ•ญ๋“ค..

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'mysql:mysql-connector-java'
    implementation 'io.jsonwebtoken:jjwt:0.9.1'
    compile 'javax.xml.bind:jaxb-api:2.1'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

UserController

@Controller
@CrossOrigin(origins = "*", allowedHeaders = "*")
public class UserController {

    private static final String ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
    private static final String CLIENT_ID = "";
    private static final String REDIRECT_URI = "";
    private static final String RESPONSE_TYPE = "code";
    private static final String SCOPE = "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile";

    private UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/login")
    public String login() {
        return "redirect:" + ENDPOINT + "?client_id=" + CLIENT_ID + "&redirect_uri=" + REDIRECT_URI
                + "&response_type=" + RESPONSE_TYPE + "&scope=" + SCOPE;
    }

    @GetMapping("/oauth/google/callback")
    public ResponseEntity<TokenResponse> oauthLogin(String code) {
        String token = userService.oauthLogin(code);
        return new ResponseEntity(new TokenResponse(token, "bearer"), HttpStatus.OK);
    }
}

UserService

@Service
public class UserService {

    private Logger logger = LoggerFactory.getLogger(UserService.class);

    private final UserRepository userRepository;
    private final OAuthService oauthService;
    private final JwtTokenProvider jwtTokenProvider;

    public UserService(UserRepository userRepository, OAuthService oauthService, JwtTokenProvider jwtTokenProvider) {
        this.userRepository = userRepository;
        this.oauthService = oauthService;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    public String oauthLogin(String code) {
        ResponseEntity<String> accessTokenResponse = oauthService.createPostRequest(code);
        OAuthToken oAuthToken = oauthService.getAccessToken(accessTokenResponse);
        logger.info("Access Token: {}", oAuthToken.getAccessToken());

        ResponseEntity<String> userInfoResponse = oauthService.createGetRequest(oAuthToken);
        GoogleUser googleUser = oauthService.getUserInfo(userInfoResponse);
        logger.info("Google User Name: {}", googleUser.getName());

        if (!isJoinedUser(googleUser)) {
            signUp(googleUser, oAuthToken);
        }
        User user = userRepository.findByEmail(googleUser.getEmail()).orElseThrow(UserNotFoundException::new);
        return jwtTokenProvider.createToken(user.getId());
    }

    private boolean isJoinedUser(GoogleUser googleUser) {
        Optional<User> users = userRepository.findByEmail(googleUser.getEmail());
        logger.info("Joined User: {}", users);
        return users.isPresent();
    }

    private void signUp(GoogleUser googleUser, OAuthToken oAuthToken) {
        User user = googleUser.toUser(oAuthToken.getAccessToken());
        userRepository.insert(user);
    }
}

OAuthService

@Service
public class OAuthService {

    private final ObjectMapper objectMapper;
    private final RestTemplate restTemplate;

    private static final String CLIENT_ID = "";
    private static final String CLIENT_SECRET = "";
    private static final String REDIRECT_URI = "";
    private static final String GRANT_TYPE = "authorization_code";

    public OAuthService(RestTemplate restTemplate) {
        this.objectMapper = new ObjectMapper().setPropertyNamingStrategy(PropertyNamingStrategy.SNAKE_CASE);
        this.restTemplate = restTemplate;
    }

    public ResponseEntity<String> createPostRequest(String code) {
        String url = "https://oauth2.googleapis.com/token";

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("code", code);
        params.add("client_id", CLIENT_ID);
        params.add("client_secret", CLIENT_SECRET);
        params.add("redirect_uri", REDIRECT_URI);
        params.add("grant_type", GRANT_TYPE);

        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-Type", "application/x-www-form-urlencoded");

        HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(params, headers);

        return restTemplate.exchange(url, HttpMethod.POST, httpEntity, String.class);
    }

    public OAuthToken getAccessToken(ResponseEntity<String> response) {
        OAuthToken oAuthToken = null;
        try {
            oAuthToken = objectMapper.readValue(response.getBody(), OAuthToken.class);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        return oAuthToken;
    }

    public ResponseEntity<String> createGetRequest(OAuthToken oAuthToken) {
        String url = "https://www.googleapis.com/oauth2/v1/userinfo";

        HttpHeaders headers = new HttpHeaders();
        headers.add("Authorization", "Bearer " + oAuthToken.getAccessToken());

        HttpEntity<MultiValueMap<String, String>> request = new HttpEntity(headers);

        return restTemplate.exchange(url, HttpMethod.GET, request, String.class);
    }

    public GoogleUser getUserInfo(ResponseEntity<String> userInfoResponse) {
        GoogleUser googleUser = null;
        try {
            googleUser = objectMapper.readValue(userInfoResponse.getBody(), GoogleUser.class);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
        return googleUser;
    }
}
  • RestTemplate : HTTP get, post ์š”์ฒญ์„ ๋‚ ๋ฆด ๋–„ ํ•„์š”ํ•˜๋‹ค.
    • ์Šคํ”„๋ง ๋นˆ์œผ๋กœ ๋“ฑ๋กํ•ด ์‚ฌ์šฉ
    • @Configuration
      public class RestTemplateConfig {
      
          @Bean
          public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
              return restTemplateBuilder
                      .requestFactory(() -> new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory()))
                      .additionalMessageConverters(new StringHttpMessageConverter(Charset.forName("UTF-8")))
                      .build();
          }
      }
      
  • ObjectMapper : JSON์œผ๋กœ ๋ฐ˜ํ™˜๋˜๋Š” ๋ฆฌํ„ด๊ฐ’์„ ๊ฐ์ฒด๋กœ ๋งŒ๋“ค์–ด ์ฃผ๊ธฐ ์œ„ํ•ด ์‚ฌ์šฉ
  • MultiValueMap :

JwtTokenProvider

@Component
public class JwtTokenProvider {

    private final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);

    private String secretKey;
    private long validityInMilliseconds;

    public JwtTokenProvider(@Value("&{security.jwt.token.secret-key}") String secretKey, @Value("${security.jwt.token.expire-length}") long validityInMilliseconds) {
        this.secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
        this.validityInMilliseconds = validityInMilliseconds;
    }

    // ํ† ํฐ ์ƒ์„ฑ
    public String createToken(Long id) {
        Claims claims = Jwts.claims().setSubject(String.valueOf(id));

        Date now = new Date();
        Date validity = new Date(now.getTime() + validityInMilliseconds); // ์œ ํšจ๊ธฐ๊ฐ„ ๊ณ„์‚ฐ (์ง€๊ธˆ์œผ๋กœ๋ถ€ํ„ฐ + ์œ ํšจ์‹œ๊ฐ„)
        logger.info("now: {}", now);
        logger.info("validity: {}", validity);

        return Jwts.builder()
                .setClaims(claims) // sub ์„ค์ •
                .setIssuedAt(now)
                .setExpiration(validity)
                .signWith(SignatureAlgorithm.HS256, secretKey) // ์•”ํ˜ธํ™”๋ฐฉ์‹?
                .compact();
    }

    // ํ† ํฐ์—์„œ ๊ฐ’ ์ถ”์ถœ
    public Long getSubject(String token) {
        return Long.valueOf(Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody()
                .getSubject());
    }

    // ์œ ํšจํ•œ ํ† ํฐ์ธ์ง€ ํ™•์ธ
    public boolean validateToken(String token) {
        Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
        return !claims.getBody().getExpiration().before(new Date());
    }
}

JWTํ† ํฐ๊ณผ ๊ด€๋ จ๋œ ์ž‘์—…์„ ๋‹ด๋‹นํ•˜๋Š” ํด๋ž˜์Šค

  • ํ† ํฐ ์ƒ์„ฑ
  • ํ† ํฐ์—์„œ (์œ ์ €)์ •๋ณด ์ถ”์ถœ
  • ํ† ํฐ ์œ ํšจ์„ฑ ํ™•์ธ

โ— ์ƒ์„ฑ์ž์˜ &{security.jwt.token.secret-key}, ${security.jwt.token.expire-length}๋Š” ํ”„๋กœ์ ํŠธ์˜ application.properties์—์„œ ์„ค์ • ๊ฐ€๋Šฅํ•˜๋‹ค.

  • application.properties
  • security.jwt.token.secret-key=Vu6WXrg3t9
    security.jwt.token.expire-length=3600000
    • secret-key๋Š” ์›ํ•˜๋Š” ์ž„์˜์˜ ๊ฐ’์„ ๋„ฃ์œผ๋ฉด ๋œ๋‹ค. random key generator ๋“ฑ์œผ๋กœ ๊ตฌ๊ธ€๋ง
    • expire-length๋Š” ms(๋ฐ€๋ฆฌ์„ธ์ปจ๋“œ)๋‹จ์œ„์ด๋‹ค. ํ† ํฐ์˜ ์œ ํšจ๊ธฐ๊ฐ„์„ ์„ค์ •ํ•˜๋Š” ๋ถ€๋ถ„์ž„

AppConfig

@Configuration
public class AppConfig implements WebMvcConfigurer {

    private static final Logger logger = LoggerFactory.getLogger(AppConfig.class);

    private final BearerAuthInterceptor bearerAuthInterceptor;

    public AppConfig(BearerAuthInterceptor bearerAuthInterceptor) { // ์ธํ„ฐ์…‰ํ„ฐ ๊ตฌํ˜„์ฒด๋ฅผ ๋งŒ๋“ค์–ด ๋“ฑ๋กํ•จ
        this.bearerAuthInterceptor = bearerAuthInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        logger.info(">>> ์ธํ„ฐ์…‰ํ„ฐ ๋“ฑ๋ก");
        registry.addInterceptor(bearerAuthInterceptor).addPathPatterns("/api/booking")
                .addPathPatterns("/api/booking/{bookingId}")
                .addPathPatterns("/api/rooms/{userId}/wish/{roomId}");
    }
}

(JWT ํ† ํฐ ๊ฒ€์ฆ์„ ์œ„ํ•œ) ์ธํ„ฐ์…‰ํ„ฐ๋ฅผ ๋“ฑ๋กํ•˜๋Š” ํด๋ž˜์Šค

  • addInterceptors()์— ๋“ฑ๋ก๋œ URL์—์„œ๋งŒ ํ† ํฐ์„ ๊ฒ€์ฆํ•˜๊ฒŒ ๋จ

BearerAuthInterceptor

AppConfig์— ๋“ฑ๋กํ•  ์ธํ„ฐ์…‰ํ„ฐ์˜ ๊ตฌํ˜„์ฒด

@Component
public class BearerAuthInterceptor implements HandlerInterceptor {

    private static final Logger logger = LoggerFactory.getLogger(BearerAuthInterceptor.class);

    private AuthorizationExtractor authorizationExtractor;
    private JwtTokenProvider jwtTokenProvider;

    public BearerAuthInterceptor(AuthorizationExtractor authorizationExtractor, JwtTokenProvider jwtTokenProvider) {
        this.authorizationExtractor = authorizationExtractor;
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        logger.info(">>> interceptor.preHandle ํ˜ธ์ถœ");
        String token = authorizationExtractor.extract(request, "Bearer");
        if (token.isEmpty()) {
            throw new TokenEmptyException();
        }

        if (!jwtTokenProvider.validateToken(token)) {
            throw new IllegalArgumentException("์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ");
        }

        Long id = jwtTokenProvider.getSubject(token);
        request.setAttribute("id", id);
        return true;
    }
}

AuthorizationExtractor

HTTP ์š”์ฒญ์œผ๋กœ๋ถ€ํ„ฐ JWTํ† ํฐ์„ ์ถ”์ถœํ•˜๋Š” ํด๋ž˜์Šค

@Component
public class AuthorizationExtractor {

    public static final String AUTHORIZATION = "Authorization";
    public static final String ACCESS_TOKEN_TYPE = AuthorizationExtractor.class.getSimpleName();

    public String extract(HttpServletRequest request, String type) {
        Enumeration<String> headers = request.getHeaders(AUTHORIZATION);
        while (headers.hasMoreElements()) {
            String value = headers.nextElement();
            if (value.toLowerCase().startsWith(type.toLowerCase())) {
                return value.substring(type.length()).trim();
            }
        }
        return Strings.EMPTY;
    }
}

OAuthToken

์•ก์„ธ์Šคํ† ํฐ ์ •๋ณด๋ฅผ ๋‹ด๊ธฐ ์œ„ํ•œ ํด๋ž˜์Šค

public class OAuthToken {

    private String accessToken;
    private String expiresIn;
    private String idToken;
    private String refreshToken;
    private String scope;
    private String tokenType;

    public OAuthToken() {
    }

    public OAuthToken(String accessToken, String expiresIn, String idToken, String refreshToken, String scope, String tokenType) {
        this.accessToken = accessToken;
        this.expiresIn = expiresIn;
        this.idToken = idToken;
        this.refreshToken = refreshToken;
        this.scope = scope;
        this.tokenType = tokenType;
    }

    public String getAccessToken() {
        return accessToken;
    }

    public String getExpiresIn() {
        return expiresIn;
    }

    public String getIdToken() {
        return idToken;
    }

    public String getRefreshToken() {
        return refreshToken;
    }

    public String getScope() {
        return scope;
    }

    public String getTokenType() {
        return tokenType;
    }
}

GoogleUser

์•ก์„ธ์Šคํ† ํฐ์„ ์‚ฌ์šฉํ•ด ๋ฐ›์•„ ์˜จ ๊ตฌ๊ธ€ ์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๋‹ด๊ธฐ ์œ„ํ•œ ํด๋ž˜์Šค

public class GoogleUser {

    public String id;
    public String email;
    public Boolean verifiedEmail;
    public String name;
    public String givenName;
    public String familyName;
    public String picture;
    public String locale;

    public GoogleUser() {
    }

    public GoogleUser(String id, String email, Boolean verifiedEmail, String name, String givenName, String familyName, String picture, String locale) {
        this.id = id;
        this.email = email;
        this.verifiedEmail = verifiedEmail;
        this.name = name;
        this.givenName = givenName;
        this.familyName = familyName;
        this.picture = picture;
        this.locale = locale;
    }

    public User toUser(String accessToken) {
        return new User(email, name, accessToken);
    }

    public String getId() {
        return id;
    }

    public String getEmail() {
        return email;
    }

    public Boolean getVerifiedEmail() {
        return verifiedEmail;
    }

    public String getName() {
        return name;
    }

    public String getGivenName() {
        return givenName;
    }

    public String getFamilyName() {
        return familyName;
    }

    public String getPicture() {
        return picture;
    }

    public String getLocale() {
        return locale;
    }
}

TokenResponse

OAuthํ† ํฐ์„ ๋‹ด๊ธฐ ์œ„ํ•œ ํด๋ž˜์Šค. (OAuthํ† ํฐ ์•ˆ์— ์•ก์„ธ์Šคํ† ํฐ์ด ๋“ค์–ด ์žˆ๋‹ค)

public class TokenResponse {

    private String accessToken;
    private String tokenType;

    public TokenResponse(String accessToken, String tokenType) {
        this.accessToken = accessToken;
        this.tokenType = tokenType;
    }

    public String getAccessToken() {
        return accessToken;
    }

    public String getTokenType() {
        return tokenType;
    }
}