JAVA

5분안에 구축하는 OAuth 서버 - 02

코린이s 2021. 12. 26. 17:39
728x90

  1. client 설정 메모리 > DB로 관리
  2. custom 로그인, 권한 동의 페이지
  3. jwt(json web token) 토큰 방식

실제 서비스에서 사용하는 구조로 세가지 사항부터 우선적으로 개선하려고 한다. 

1번의 경우에는, 클라이언트 설정을 코드로 관리하기는 어렵고 수정 사항 발생시 코드를 수정하여 배포해야 하기때문에 DB로 관리하여 수정 사항을 배포 없이 바로 반영하며 가독성도 좋다.

2번의 경우에는, 로그인, 권한 동의 페이지를 예제처럼 구현하는 경우는 없을것으로 보여 내가 원하는 디자인으로 바꾸는 작업을 진행 한다.

3번의 경우에는, '3) 유효한 토큰인지 검사' 시 호출 시점 마다 인증서버와 통신하여 정상 토큰 여부를 검증하는데 jwt를 사용하면 리소스 서버 기동시 한번만 인증서버와 통신하면 그 이후에는 리소스 서버에서 자체적으로 검증이 가능하여 네트워크 통신 비용을 절약할 수 있다. (쉽게 말하면 jwt 방식 사용시 서비스 부하가 적다.)

자 1번 부터 시작 해보자 !

테이블을 만듭니다.

-- 클라이언트 정보 테이블
CREATE TABLE IF NOT EXISTS `oauth_client_details`
(
    `client_id`               VARCHAR(256)  NOT NULL,
    `resource_ids`            VARCHAR(256)  NULL,
    `client_secret`           VARCHAR(256)  NULL,
    `scope`                   VARCHAR(256)  NULL,
    `authorized_grant_types`  VARCHAR(256)  NULL,
    `web_server_redirect_uri` VARCHAR(256)  NULL,
    `authorities`             VARCHAR(256)  NULL,
    `access_token_validity`   INT           NULL,
    `refresh_token_validity`  INT           NULL,
    `additional_information`  VARCHAR(4096) NULL,
    `autoapprove`             VARCHAR(256)  NULL,
    PRIMARY KEY (`client_id`)
);

-- 권한 관리 테이블
create table oauth_approvals
(
    userId         VARCHAR(256),
    clientId       VARCHAR(256),
    scope          VARCHAR(256),
    status         VARCHAR(10),
    expiresAt      TIMESTAMP,
    lastModifiedAt TIMESTAMP
);

메모리에 저장하는 부분을 DB로 변경 합니다.

    // client 설정 DB 저장
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.jdbc(dataSource);
    }
    
    // 권한 동의 DB 저장
    @Bean
    public ApprovalStore approvalStore() {
        return new JdbcApprovalStore(dataSource);
    }
    
    // 인증, 토큰 설정
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager) // grant_type password를 사용하기 위함 (manager 지정 안할시 password type 으로 토큰 발행시 Unsupported grant type: password 오류 발생)
                .userDetailsService(userDetailService) // refrash token 발행시 유저 정보 검사 하는데 사용하는 서비스 설정
                .tokenStore(tokenStore())
                .approvalStore(approvalStore()); // 권한 동의 설정
    }

클라이언트 정보를 DB 에 저장 합니다.

INSERT INTO `oauth_client_details`(
  `client_id`,
  `resource_ids`,
  `client_secret`,
  `scope`,
  `authorized_grant_types`,
  `web_server_redirect_uri`,
  `authorities`,
  `access_token_validity`,
  `refresh_token_validity`,
  `additional_information`,
  `autoapprove`
  ) VALUES(
  'clientId',
  null, -- 해당 클라이언트가 특정 리소스 서버에만 접속하기를 원할 경우 사용
  '{noop}secretKey',
  'read, write',
  'authorization_code,implicit,password,client_credentials,refresh_token',
  'http://localhost:8081/callback',
  'TEST_ROLE',
  60,
  3600,
  null ,
  'false' -- 설정은 true 했었지만 권한 동의 화면도 구성할것이기에 false 로 하여 권한 동의 화면을 띄운다.
  );

자 그럼 인증 서버를 실행시켜서 테스트 해봅니다!

- 로그인 페이지 접속 (http://localhost:8081/oauth/authorize?client_id=clientId&redirect_uri=http://localhost:8081/callback&response_type=code)

로그인이 정상적으로 되면 권한 동의 페이지가 나오며 read, write 권한 모두 동의한다.

동의 완료시 아래와 같이 토큰 발행이 된다.

권한 동의 테이블을 보면 아래와 같이 1년 유효기간으로 저장이 되며, 해당 DB 데이터를 지우거나 유효기간이 지나면 권한 동의 창이 다시 활성화 된다.

자 2번 시작 해보자 !

권한 동의 화면 부터 변경해본다!

// LoginController.class

package com.code.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.oauth2.provider.AuthorizationRequest;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;

import javax.servlet.http.HttpServletRequest;

@Slf4j
@Controller
public class LoginController {

    ...

    // 권한 동의 커스텀
    @RequestMapping("/oauth/confirm_access")
    public String confirm(HttpServletRequest request){
        AuthorizationRequest authorizationRequest = (AuthorizationRequest) request.getSession().getAttribute("authorizationRequest");
        log.error("## => {}", authorizationRequest.getClientId()); // 세션에 로그인에 필요한 정보가 담겨 있다.
        return "confirm";
    }
}

// confirm.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <!--bootstrap-->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js" integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js" integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13" crossorigin="anonymous"></script>
</head>
<body>


<form action="/oauth/authorize" method="post">
    <!--권한 동의시 필요한 데이터 (기존 페이지와 동일하게 구성)-->
    <input type="hidden" class="form-control" name="scope.read" value="true">
    <input type="hidden" class="form-control" name="scope. write" value="true">
    <input type="hidden" class="form-control" name="user_oauth_approval" value="true">
    <div class="mb-7 row" style="margin-top: 5%; margin-left:5%; margin-right:5%">
        <div class="mb-3 row">
            권한 동의 하실꺼 ?
        </div>
        <div class="mb-1 row">
            <button type="submit" class="btn btn-warning mb-3">확인</button>
        </div>
    </div>
</form>
</body>
</html>

실행하고 로그인 해보면 아래와 같이 변경한 권한 동의 화면이 나온다

// 로그 

2021-12-25 21:51:38.876 ERROR 33866 --- [nio-8081-exec-4] com.code.controller.LoginController      : ## => clientId

확인을 누르면 권한 동의 테이블에 정상적으로 저장되며, 토큰도 정상적으로 발행 되며 이후 접속시 권한 동의를 이미 했기에 노출 되지 않는다.

테이블에 저장된 화면
토큰 발행된 화면

이제 로그인 페이지를 수정해본다!

/ / WebSecurityConfigurerAdapter.class

@EnableWebSecurity // 웹보안 활성화
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable() // csrf 공격을 막기 위해 state 값을 전달 받지 않는다
                .formLogin() // 로그인 페이지 설정
                .loginPage("/oauth/login") // 커스텀 로그인 페이지 설정
                .and()
                .httpBasic(); // http 통신으로 basic auth를 사용 할 수 있다. (ex: Authorization: Basic bzFbdGfmZrptWY30YQ==)
    }
...
}

// LoginConroller.class

@Slf4j
@Controller
public class LoginController {

    @RequestMapping(value = "/oauth/login", method = RequestMethod.GET)
    public String login(){
        return "login";
    }
...
}

// login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <!--bootstrap-->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js" integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js" integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13" crossorigin="anonymous"></script>
</head>
<body>
<!--커스텀 로그인 페이지 URL을 POST 로 보내면 인증 처리 된다.-->
<form action="/oauth/login" method="post">
    <div class="mb-7 row" style="margin-top: 5%; margin-left:5%; margin-right:5%">
        <div class="mb-3 row">
            <label for="username" class="col-sm-2 col-form-label">ID</label>
            <div class="col-sm-10">
                <input type="text" class="form-control" id="username" name="username">
            </div>
        </div>
        <div class="mb-3 row">
            <label for="Password" class="col-sm-2 col-form-label">Password</label>
            <div class="col-sm-10">
                <input type="password" class="form-control" id="password" name="password">
            </div>
        </div>
        <div class="mb-1 row">
            <button type="submit" class="btn btn-warning mb-3">LOGIN</button>
        </div>
    </div>
</form>
</body>
</html>

자 테스트 해본다!

로그인 페이지 접속 (http://localhost:8081/oauth/authorize?client_id=clientId&redirect_uri=http://localhost:8081/callback&response_type=code)하면 커스텀 페이지로 나온다.

로그인 완료 시 아래와 같이 권한 동의 화면도 커스텀 페이지로 정상적으로 나온다.

토큰도 정상적으로 발행 된다.

자 마지막으로 3번 시작 해보자 !

- JWT 토큰으로 사용시 리소스서버에서 자체적으로 유효 토큰 체크가 가능하기에 토큰 저장, 유효성 체크 URL 허용(/oauth/check_token) 설정 제거

- JWT 암호화 방식 설정 (대칭키, 비대칭키 암호화 방식중 선택하여 주석 풀고 설정)

1. 대칭키 암호화 : 암호화 복호화 키가 같음, 설정할 키만 정하면 된다.

2. 비대칭키 암호화 : 암호화 복호화 키가 다름, 보안성이 대칭키보다 높음, 저자는 비대칭키 종류중 RSA 암호화를 사용 (자바에서 제공하는 keytool 사용, 아래 방법 기재)

public class Oauth2AuthorizationConfig extends AuthorizationServerConfigurerAdapter{
...
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.tokenKeyAccess("permitAll()") // 모두 허용하지 않으면 '/oauth/token_key' URL은 404 에러와 토큰을 DB에 저장 할 수 없어 필수 (isAuthenticated()로 설정시 'token_key' 호출시 404 에러 발생 > 모두 허용 또는 허용 안함중에 하나 인것으로 보임)
                //.checkTokenAccess("isAuthenticated()") // 인증된 사용자만 토큰 유효성 체크 '/oauth/check_token' 가능 (JWT 일때는 필요 없음)
                .allowFormAuthenticationForClients();
    }

    // 토큰 DB 저장
    /*@Bean
    public TokenStore tokenStore(){
        return new JdbcTokenStore(dataSource);
    }*/

    // 인증, 토큰 설정
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager) // grant_type password를 사용하기 위함 (manager 지정 안할시 password type 으로 토큰 발행시 Unsupported grant type: password 오류 발생)
                .userDetailsService(userDetailService) // refrash token 발행시 유저 정보 검사 하는데 사용하는 서비스 설정
                //.tokenStore(tokenStore()) // jwt 로 변경시 토큰 저장하지 않아도 리소스 서버에서 차제적으로 체크 가능
                .accessTokenConverter(jwtAccessTokenConverter())
                .approvalStore(approvalStore());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter(){
        // RSA 암호화 : 비 대칭키 암호화 : 공개키로 암호화 하면 개인키로 복호화
        KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwtkey.jks"), "corin1234".toCharArray());
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(keyStoreKeyFactory.getKeyPair("jwtkey"));

        // 대칭키 암호화 : key 값은 리소스 서버에도 넣고 하면 됨.
         /*JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("key");*/

        return converter;
    }

}

keytool 은 아래와 같이 쉽게 생성 가능하며, 설정도 생성할때 입력한 정보로 넣는다.

$keytool -genkeypair -alias Key의 별칭
                    -keyalg RSA 
                    -keypass Key 암호
                    -keystore 저장될 key의 파일명 
                    -storepass 저장될 파일의 암호

상세 정보는 엔터로 넘어가도 되며, 마지막에만 y 를 눌러 완료 한다.

생성된 key는 프로젝트의 resources 에 넣는다.

프로젝트를 실행하여 확인해보자!

첫번째로 키 정보를 정상적으로 가져오는지 확인한다.

그리고 키 발급시 jwt 토큰으로 나오는것을 볼수있다.

jwt 토큰 해당 사이트(https://jwt.io/)에서 인코드 된 값을 확인할 수 있다.

JWT 토큰 관련 설명은 해당 사이트에서 확인 :: https://co-de.tistory.com/20

 

[로그인] 로그인 유지는 어떻게 처리 하는 걸까?

인증 정보를 주고 받는 방식은 두가지 방법이 있다. 쿠키 & 세션 JWT 하나씩 알아보자! # 쿠키 & 세션 1. 로그인 2. 사용자 정보 요청 따라서 쿠키의 만료시간에 따라 로그인 유지가 된다. 쿠키의 만

co-de.tistory.com

이제 리소스 서버를 수정해본다!

// application.yml

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://localhost:8081/oauth/token_key

// Oauth2ResourceConfig.class

public class Oauth2ResourceConfig extends ResourceServerConfigurerAdapter {

    @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
    private String publicKeyUri; // key 정보를 받아오기 위한 인증서버 URL 선언
...

    // 토큰 유효성 체크가 불필요해져서 주석 처리
    /*@Override
    public void configure(ResourceServerSecurityConfigurer resources) {
        RemoteTokenServices remoteTokenService = new RemoteTokenServices();
        remoteTokenService.setClientId("clientId");
        remoteTokenService.setClientSecret("secretKey");
        remoteTokenService.setCheckTokenEndpointUrl("http://localhost:8081/oauth/check_token");
        resources.tokenServices(remoteTokenService);
    }*/

	// 토큰 저장소를 jwt 체크하는것으로 수정
    @Bean
    public TokenStore tokenStore(){
        return new JwtTokenStore(jwtAccessTokenConverter());
    }
	
    // 공개키를 직접 파일로 저장하여 불러와도 되고, 저자는 인증 서버를 직접 호출해서 서버 기동시 한번만 얻어온다.
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter(){
        try{
            /***
             * 공개키를 직접 파일로 만들어 읽어서 jwt 디코드 키 등록
             */
           /* JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
            Resource resource = new ClassPathResource("key.txt");
            converter.setVerifierKey(IOUtils.toString(resource.getInputStream()));
            return converter;*/

            /***
             * 직접 oauth 서버를 호출하여 공개키 읽어서 jwt 디코드 키 등록
             */
            JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
            converter.setVerifierKey(getPublicKeyValue(publicKeyUri));
            return converter;
        }catch (Exception e){
            return new JwtAccessTokenConverter();
        }
    }

    private String getPublicKeyValue(String uriKey) {
        JsonNode response = Unirest.get(uriKey)
                .asJson().getBody();
        return StringUtils.isEmpty(response.toString()) ? "" : new Gson().fromJson(response.toString(), JWTKey.class).getValue();
    }

}

리소스 기동시에 아래와 같이 access log 확인해보면 공개키를 얻기위한 호출이 들어온것을 볼수 있다.

// 인증 서버 access.log

토큰을 발행 받아 resource 서버 호출시 정상적으로 호출 되었다.

만료시 아래와 같이 401 인증 오류가 발생한다.

다음 글에서는 지금보다 더 고도화 하도록 한다! 뿅!

branch-02 참고 :: https://github.com/works-code/oauth2

 

GitHub - works-code/oauth2: 자체적 oauth 2 구축 스타터 코드

자체적 oauth 2 구축 스타터 코드. Contribute to works-code/oauth2 development by creating an account on GitHub.

github.com

branch-02 참고 :: https://github.com/works-code/oauth2-resource

 

GitHub - works-code/oauth2-resource: oauth2-resource

oauth2-resource. Contribute to works-code/oauth2-resource development by creating an account on GitHub.

github.com

 

728x90