SpringSecurity + OpenAM + OAuth2

SpringSecurity OAuth2でOpenAMとの連携を試します。

/privateは認証を必要とするエンドポイントで、/publicは認証が不要なエンドポイントです。
また、ログは可能な限り出力する方針とします。SpringSecurityのデバッグログに加えてRestTemplateのリクエストとレスポンスもログに出力します。

1. OpenAMの実行と設定

まずはOpenAMを実行します。

1
docker run -h openam-01.example.com -p 8080:8080 --name openam-01 openidentityplatform/openam:14.6.5
  1. パスワードの設定しTopLevelのレルムにOAuth2のアプリケーションを作成します。名前は test、パスワードは password としました。
  2. Redirection URIshttp://localhost:9000/login/oauth2/code/openam を追加します。
  3. Scopeemail を追加します。

2. アプリケーションの作成

application.yml

まずは設定ファイル。8080ポートはOpenAMが使っているので9000にしました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
server:
port: 9000

spring:
security:
oauth2:
client:
registration:
openam:
client-id: test
client-secret: password
authorization-grant-type: authorization_code
scope: email
redirect-uri: http://localhost:9000/login/oauth2/code/openam
client-authentication-method: Basic
client-name: clientName
provider:
openam:
authorization-uri: http://localhost:8080/openam/oauth2/authorize
user-info-uri: http://localhost:8080/openam/oauth2/userinfo
token-uri: http://localhost:8080/openam/oauth2/access_token
# jwk-set-uri: http://localhost:8080/openam/oauth2/connect/jwk_uri
user-name-attribute: sub
user-info-authentication-method: authorization_code

# http://localhost:9000/private

logging:
level:
root: DEBUG

RestTemplateLoggingInterceptor.java

RestTemplateのログ出力用インターセプターです。説明は省きます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85

@Slf4j
public class RestTemplateLoggingInterceptor implements ClientHttpRequestInterceptor {

@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
traceRequest(request, body);
ClientHttpResponse response = new BufferingClientHttpResponseWrapper(execution.execute(request, body));
traceResponse(response);
return response;
}

private void traceRequest(HttpRequest request, byte[] body) throws IOException {
log.info("===========================request begin================================================");
log.debug("URI : {}", request.getURI());
log.debug("Method : {}", request.getMethod());
log.debug("Headers : {}", request.getHeaders());
log.debug("Request body: {}", new String(body, "UTF-8"));
log.info("==========================request end================================================");
}

private void traceResponse(ClientHttpResponse response) throws IOException {
StringBuilder inputStringBuilder = new StringBuilder();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(response.getBody(), "UTF-8"));
String line = bufferedReader.readLine();
while (line != null) {
inputStringBuilder.append(line);
inputStringBuilder.append('\n');
line = bufferedReader.readLine();
}
log.info("============================response begin==========================================");
log.debug("Status code : {}", response.getStatusCode());
log.debug("Status text : {}", response.getStatusText());
log.debug("Headers : {}", response.getHeaders());
log.debug("Response body: {}", inputStringBuilder.toString());
log.info("=======================response end=================================================");
}

private static class BufferingClientHttpResponseWrapper implements ClientHttpResponse {

private final ClientHttpResponse response;

@Nullable
private byte[] body;

BufferingClientHttpResponseWrapper(ClientHttpResponse response) {
this.response = response;
}


@Override
public HttpStatus getStatusCode() throws IOException {
return this.response.getStatusCode();
}

@Override
public int getRawStatusCode() throws IOException {
return this.response.getRawStatusCode();
}

@Override
public String getStatusText() throws IOException {
return this.response.getStatusText();
}

@Override
public HttpHeaders getHeaders() {
return this.response.getHeaders();
}

@Override
public InputStream getBody() throws IOException {
if (this.body == null) {
this.body = StreamUtils.copyToByteArray(this.response.getBody());
}
return new ByteArrayInputStream(this.body);
}

@Override
public void close() {
this.response.close();
}
}
}

PrivateController.java, PublicController.java

Restコントローラーです。説明は省きます。

1
2
3
4
5
6
7
8
@RestController
@RequestMapping("private")
public class PrivateController {
@GetMapping("")
public String index() {
return "Private";
}
}
1
2
3
4
5
6
7
8
@RestController
@RequestMapping("public")
public class PublicController {
@GetMapping("")
public String index() {
return "Public";
}
}

CustomAuthenticationSuccessHandler

AuthenticationSuccessHandlerです。今回なくても問題ないですがログだけ出力するようにしました。

1
2
3
4
5
6
7
8
9
10
11
@Slf4j
@Component
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
log.warn("onAuthenticationSuccess");
super.onAuthenticationSuccess(request, response, authentication);
}
}

OAuth2UserService.java

OAuth2UserServiceです。ログ出力のためにRestTemplateを上書きします。処理自体はデフォルトのままです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Slf4j
@Component
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();

@PostConstruct
public void postConstruct() {
var restTemplate = new RestTemplate();
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
restTemplate.setInterceptors(List.of(new RestTemplateLoggingInterceptor()));
delegate.setRestOperations(restTemplate);
}

@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
log.info("CustomOAuth2UserService.loadUser");
OAuth2AccessToken accessToken = userRequest.getAccessToken();
log.info("accessToken: " + accessToken.getTokenValue());

// Delegate to the default implementation for loading a user
var oauth2User1 = delegate.loadUser(userRequest);

// Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

var oauth2User2 = new DefaultOAuth2User(oauth2User1.getAuthorities(), oauth2User1.getAttributes(), "sub");
return oauth2User2;
}
}

Application.java

SpringSecurityのデバッグをtrueに設定してます。プロダクション環境ではfalseにしましょう。

1
2
3
4
5
6
7
8
@SpringBootApplication
@EnableWebSecurity(debug = true)
public class Application {

public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

SecurityConfig.java

SpringSecurityの設定クラスです。

設定内容は以下のとおりです。

  • CustomAuthenticationSuccessHandlerを使用します。
  • CustomOAuth2UserServiceを使用します。
  • accessTokenResponseClientを設定します。その際、RestTemplateのログを出力するようにします。
  • /,/publicは誰でもアクセスできます。それ以外は認証が必要です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {

private final CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;

private final CustomOAuth2UserService customOAuth2UserService;

@Bean
public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {
var accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
var tokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
var restTemplate = new RestTemplate(Arrays.asList(
new FormHttpMessageConverter(), tokenResponseHttpMessageConverter));
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
restTemplate.setInterceptors(List.of(new RestTemplateLoggingInterceptor()));

accessTokenResponseClient.setRestOperations(restTemplate);
return accessTokenResponseClient;
}

/*
http://localhost:9000/private
*/

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.oauth2Login()
.successHandler(customAuthenticationSuccessHandler)
.tokenEndpoint().accessTokenResponseClient(accessTokenResponseClient()).and()
.userInfoEndpoint().userService(customOAuth2UserService).and()
.and()
.authorizeHttpRequests(authz -> authz
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.mvcMatchers("/", "/public").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}

処理の流れ

処理の流れは以下のとおりです。

  1. ブラウザで http://localhost:9000/private にアクセスする
  2. アプリケーションからOpenAMへのリダイレクト指示が返るので、OpenAMのログインページにリダイレクトする
    http://localhost:8080/openam/oauth2/authorize?response_type=code&client_id=test&scope=email&state=Sz61Zw0rWrT_GQZsRQqvg-HL_Z8tAzKuCXU2TTkSGX0%3D&redirect_uri=http://localhost:9000/login/oauth2/code/openam
  3. ID、パスワードを入力しOpenAMにログインする
  4. OpenAMのレスポンスからアプリケーションへのリダイレクト指示が返るので、アプリケーションにリダイレクトする
    http://localhost:9000/login/oauth2/code/openam?code=bf5079d8-6ee3-4ee3-8c50-085ce6ab0e01&scope=email&iss=http%3A%2F%2Flocalhost%3A8080%2Fopenam%2Foauth2&state=Sz61Zw0rWrT_GQZsRQqvg-HL_Z8tAzKuCXU2TTkSGX0%3D&client_id=test
  5. アプリケーションがOpenAMのAPIを呼び出してアクセストークンを取得する
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    ===========================request begin================================================
    URI : http://localhost:8080/openam/oauth2/access_token
    Method : POST
    Headers : [Accept:"application/json;charset=UTF-8", Content-Type:"application/x-www-form-urlencoded;charset=UTF-8", Authorization:"Basic dGVzdDpwYXNzd29yZA==", Content-Length:"147"]
    Request body: grant_type=authorization_code&code=bf5079d8-6ee3-4ee3-8c50-085ce6ab0e01&redirect_uri=http%3A%2F%2Flocalhost%3A9000%2Flogin%2Foauth2%2Fcode%2Fopenam
    ==========================request end================================================
    ============================response begin==========================================
    Status code : 200 OK
    Status text :
    Headers : [X-Frame-Options:"SAMEORIGIN", Cache-Control:"no-store", Date:"Sat, 11 Jun 2022 07:16:32 GMT", Accept-Ranges:"bytes", Server:"Restlet-Framework/2.4.0", Vary:"Accept-Charset, Accept-Encoding, Accept-Language, Accept", Pragma:"no-cache", Content-Type:"application/json", Transfer-Encoding:"chunked", Keep-Alive:"timeout=20", Connection:"keep-alive"]
    Response body: {"access_token":"7b653046-cc64-48bf-aab1-151de16936a4","scope":"email","token_type":"Bearer","expires_in":3599}
    =======================response end=================================================
  6. アプリケーションがOpenAMのAPIを呼び出してユーザー情報を取得する
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    ===========================request begin================================================
    URI : http://localhost:8080/openam/oauth2/userinfo
    Method : GET
    Headers : [Accept:"application/json", Authorization:"Bearer 7b653046-cc64-48bf-aab1-151de16936a4", Content-Length:"0"]
    Request body:
    ==========================request end================================================
    ============================response begin==========================================
    Status code : 200 OK
    Status text :
    Headers : [X-Frame-Options:"SAMEORIGIN", Date:"Sat, 11 Jun 2022 07:16:32 GMT", Accept-Ranges:"bytes", Server:"Restlet-Framework/2.4.0", Vary:"Accept-Charset, Accept-Encoding, Accept-Language, Accept", Content-Type:"application/json;charset=UTF-8", Transfer-Encoding:"chunked", Keep-Alive:"timeout=20", Connection:"keep-alive"]
    Response body: {"sub":"Taro","email":"taro@example.com"}
    =======================response end=================================================
  7. もともとアクセスしようとしていた http://localhost:9000/private にリダイレクトする

ソースコード

https://github.com/pecolynx/spring-security-openam-oauth2