Spring Boot/SecurityでOAuthクライアントを実装してみた(LINEログイン)


####1. 認可サーバ設定(LINE)
LINE Developers:
https://developers.line.biz/ja

・Channel Type: LINE Login
・App Types: Web app
・Callback URL: http\://localhost:8080/redirect (認証後のリダイレクト先URL)

####2. クライアント実装(Spring Boot/Security)
2-1. 依存ライブラリの追加

```xml:pom.xml
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
        <dependency>
        <groupId>org.springframework.security.oauth.boot</groupId>
        <artifactId>spring-security-oauth2-autoconfigure</artifactId>
    </dependency>
```

#####2-2. application.ymlの設定
以下内容をもとに、ユーザーをLINE認証画面に遷移させ、LINE API を呼び出してユーザー情報を取得

**security.oauth2.client.clientId**
　- Channnel ID
**security.oauth2.client.clientSecret**
　- Channnel Secret
**security.oauth2.client.accessTokenUri**
　- アクセストークン発行の際に呼び出すエンドポイント
**security.oauth2.client.userAuthorizationUri**
　- 認証先エンドポイント
**security.oauth2.client.pre-established-redirect-uri**
　- 認証後のリダイレクト先URL (※LINE側で設定したCallback URL)
**security.oauth2.client.scope**
　- トークンをリクエストする際にクライアントが使えるスコープのリスト
**security.oauth2.client.clientAuthenticationScheme**
　- OAuth2 Token Endpoint 呼び出しに使うフォーマット(※LINEはFORMに対応)
**security.oauth2.resource.userInfoUri**
　- ユーザー情報(ユーザーID・ユーザ名・プロフィール画像)取得のために提供されるエンドポイント

```yml:application.yml
spring:
  main:
    allow-bean-definition-overriding: true
security:
  oauth2:
    client:
      clientId: [CLIENT_ID]
      clientSecret: [CLIENT_SECRET]
      accessTokenUri: https://api.line.me/oauth2/v2.1/token
      userAuthorizationUri: https://access.line.me/oauth2/v2.1/authorize
      use-current-uri: false
      pre-established-redirect-uri: https://localhost:8443/redirect
      authorized-grant-types:
      - authorization_code
      - refresh_token
      clientAuthenticationScheme: form
      scope:
        - openid
        - email
        - profile
    resource:
      userInfoUri: https://api.line.me/oauth2/v2.1/userinfo
      preferTokenInfo: true
server:
  port: 8443
  ssl:
    key-store: [.p12ファイルパス]
    key-store-password: [パスワード]
    keyStoreType: PKCS12
    keyAlias: MyAlias
logging:
  level:
     org.springframework.web: DEBUG    
```

#####2-3. application.ymlから変数情報を取得

```java:ConfigComponent.java
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.PropertySource;
import org.springframework.stereotype.Component;

@Component
@PropertySource("classpath:application.yml")
@ConfigurationProperties(prefix = "security.oauth2.client")
public class ConfigComponent {

    private String accessTokenUri;

    private String clientId;

    private String clientSecret;
    
    @Value("${security.oauth2.client.pre-established-redirect-uri}")
    private String redirecturi;
    
    @Value("${security.oauth2.resource.userInfoUri}")
    private String userInfoUri;

    public String getAccessTokenUri() {
        return this.accessTokenUri;
    }
    
    public void setAccessTokenUri(String accessTokenUri) {
        this.accessTokenUri = accessTokenUri;
    }

    public String getClientId() {
        return this.clientId;
    }
    
    public void setClientId(String clientId) {
        this.clientId = clientId;
    }

    public String getRedirecturi() {
        return this.redirecturi;
    }

    public void setRedirecturi(String redirecturi) {
        this.redirecturi = redirecturi;
    }
    
    public String getClientSecret() {
        return this.clientSecret;
    }
    public void setClientSecret(String clientSecret) {
        this.clientSecret = clientSecret;
    }
    
    public String getUserInfoUri() {
        return this.userInfoUri;
    }
    public void setUserInfoUri(String userInfoUri) {
        this.userInfoUri = userInfoUri;
    }
}
```


#####2-4. @Beanの登録
　- 上記で設定した変数情報取得用クラスをBeanに登録

```java:ConfigBean.java
package com.example.demo;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class BeanConfig {

    @Bean
    public ConfigComponent configComponent() {
        return new ConfigComponent();
    }
    
    @Bean
    public LoginRestClient loginRestClient() {
        return new LoginRestClient();
    }
}
```

#####2-5. ユーザが指定パス(/login)にアクセスした際にOAuth認証を実行

```java:SecurityConfig.java
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

import org.springframework.boot.autoconfigure.security.oauth2.client.EnableOAuth2Sso;

@EnableWebSecurity
@EnableOAuth2Sso
public class SecurityConfig extends WebSecurityConfigurerAdapter {
	
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers(
                            "/images/**",
                            "/css/**",
                            "/javascript/**",
                            "/webjars/**",
                            "/favicon.ico");
    }
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    	http
	    	.authorizeRequests()
		        .antMatchers("/redirect/**")
		        	.permitAll()
		        .and()
	        .authorizeRequests()
		        .antMatchers("/login/**")
		        .authenticated();
    }   
}
```

#####2-5. @Controllerの定義
　- 認証完了後に、認可コードを取得し、アクセストークン発行/ユーザ情報取得リクエストを実行
　- @AutowiredでLoginRestClientクラスをInject
　- ModelAndViewクラスでThymeleafテンプレートへ取得したオブジェクトを渡す

```java:LoginController.java
import static java.util.Objects.requireNonNull;

import java.io.IOException;
import java.util.stream.IntStream;

import com.example.demo.LoginRestClient;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;

@Controller
public class LoginController {
	
	@Autowired
	LoginRestClient loginRestClient;
	
	@RequestMapping(value = "/redirect", method = RequestMethod.GET)
	public String getToken(@RequestParam("code") String code,
			HttpServletRequest req, HttpServletResponse res) throws IOException{
	  LineToken responseToken = loginRestClient.getToken(code);
	  String accessTK = responseToken.getAccessTK();
	  String refreshTK = responseToken.getRefreshTK();
	  String idTK = responseToken.getIdTK();
	  
	  Cookie accessTKCookie = new Cookie("access_token", accessTK);
	  accessTKCookie.setSecure(true);
	  Cookie refreshTKCookie = new Cookie("refresh_token", refreshTK);
	  refreshTKCookie.setSecure(true);
	  Cookie idTKCookie = new Cookie("id_token", idTK);
	  idTKCookie.setSecure(true);
	  
	  res.addCookie(accessTKCookie);
	  res.addCookie(refreshTKCookie);
	  res.addCookie(idTKCookie);
	  
	  return "redirect:/";
    }

    @RequestMapping(value = "/userinfo", method = RequestMethod.GET)
    public ModelAndView getSubInfo(ModelAndView mv, HttpServletRequest req) throws IOException {
    	Cookie cookies[] = req.getCookies();
    	for(Cookie c: cookies) {
    		if(c.getName().equals("access_token")) {
    			System.err.println(c.getValue());
    			JSONObject userInfo =
    					new JSONObject(loginRestClient.getUserInfo(c.getValue()).getBody());

    		  	String sub = userInfo.getString("sub");
    		  	String name = userInfo.getString("name");
    		  	String picture = userInfo.getString("picture");

    			mv.addObject("sub", sub);
    			mv.addObject("name", name);
    			mv.addObject("picture", picture);
    			mv.setViewName("userinfo");
    		}
    	}
    	return mv;
    	
    	}
    
	@RequestMapping(value = "/", method = RequestMethod.GET)
 	public ModelAndView getTopPage(ModelAndView mv, HttpServletRequest req) {
    	Cookie cookies[] = req.getCookies();
    	for(Cookie c: cookies) {
    		if(c.getName().equals("access_token")) {
    			mv.addObject("access_token", c.getValue());
    		} else if (c.getName().equals("refresh_token")) {
    			mv.addObject("refresh_token", c.getValue());
    		} else if (c.getName().equals("id_token")) {
    			mv.addObject("id_token", c.getValue());
    		}
    	}

    	mv.setViewName("index");
    	
    	return mv;
     }
}
```

#####2-6. アクセストークン発行/ユーザ情報取得をリクエスト実行クラス
　- getTokenメソッドで認可コードを引数にアクセストークン発行APIをリクエスト
　- getUserInfoメソッドにてアクセストークンをAuthorizationヘッダーに付与することでユーザ情報を取得

```java:LoginRestClient.java
import java.io.IOException;
import java.util.Map;
import java.util.Objects;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

@Component
public class LoginRestClient {
	
	final static RestTemplate rs = new RestTemplate();

	@Autowired
	private ConfigComponent cc;
	
    public LineToken getToken(String code) throws IOException {
    	code = Objects.requireNonNull(code);
    	HttpHeaders headers = new HttpHeaders();
    	headers.setBasicAuth(cc.getClientId(), cc.getClientSecret());
    	headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
    	
    	MultiValueMap<String, Object> params = new LinkedMultiValueMap<>();
    	params.add("code", code);
    	params.add("redirect_uri", cc.getRedirecturi());
    	params.add("grant_type", "authorization_code");
    	
    	HttpEntity<MultiValueMap<String, Object>> requestEntity 
    		= new HttpEntity<>(params, headers);
    	
        LineToken response = rs
                .postForObject(cc.getAccessTokenUri(), requestEntity, LineToken.class);
    	
    	return response;
    }
    
    public ResponseEntity<String> getUserInfo(String accessToken) throws IOException {
    	accessToken = Objects.requireNonNull(accessToken);
    	HttpHeaders headers = new HttpHeaders();
    	headers.setBearerAuth(accessToken);
    	
    	HttpEntity<MultiValueMap<String, Object>> requestEntity 
			= new HttpEntity<>(headers);
    	
    	 ResponseEntity<String> response =
    			 rs.exchange(cc.getUserInfoUri(), HttpMethod.GET, requestEntity, String.class);
    	
    	System.err.println(response);
    	
    	return response;
    }

}
```

#####2-7. アクセストークン/リフレッシュトークン/IDトークンをPOJO変換

```java:LineToken.java
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;

import static java.util.Objects.requireNonNull;

public final class LineToken {
    public final String accessToken;
    public final String idToken;
    public final String refreshToken;

    @JsonCreator
    LineToken(
        @JsonProperty("access_token") String accessToken,
        @JsonProperty("id_token") String idToken,
        @JsonProperty("refresh_token") String refreshToken
    ) {
        this.accessToken = requireNonNull(accessToken, "accessToken");
        this.idToken = requireNonNull(idToken, "idToken");
        this.refreshToken = requireNonNull(refreshToken, "refreshToken");
    }
    
    public String getAccessTK() {return this.accessToken;}
    public String getIdTK() {return this.idToken;}
    public String getrefreshTK() {return this.refreshToken;}
    
}
```


####3. 検証
#####3-1. https\://localhost:8443/loginへアクセス

#####3-2. LINEログイン画面へリダイレクト
<img width="333" alt="キャプチャ.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398780/b777ca7a-e286-d758-abd6-8f4f8afa7d08.png">


#####3-3. ログイン実行
　- リフレッシュトークンにより、アクセストークンの有効期限満了後(30日後)も、当該トークンのリクエストによりアクセストークンの再発行が可能。
　- リフレッシュトークンはアクセストークン満了後、最長10日間有効。
<img width="667" alt="キャプチャ.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398780/26675058-b86a-558a-a88a-07845b001af5.png">
<img width="551" alt="キャプチャ.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398780/a8c3e4fa-7b29-2aac-c57e-349cda3bca93.png">

#####3-4. id_tokenをデコード
ID TokenがJWT形式で返却されているため、ヘッダー・ペイロード部分をBase64デコードし、中身を参照

ヘッダー:
{"typ":"JWT","alg":"HS256"}

ペイロード:
{"iss":"https\://access.line.me","sub":"**********","aud":"1653797412","exp":1579957272,"iat":1579953672,"amr":["linesso"],"name":"Yu","picture":"https\://profile.line-scdn.net/0ho4KGrFFFMBt2PhvCq9pPTEp7PnYBEDZTDg98LVpsanhTXn4aTFx5Lwdpbn9SWnEYHwgtfFs3Znhd"}

|クレーム名  |クレーム内容  |
|:-:|---|
|iss  |トークンの発行者(ISSuer) |
|sub  |トークンの対象者(SUBubect)  |
|aud  |トークンの受け手(AUDience)  |
|exp  |トークンの有効期限(EXPiration time)  |
|iat  |トークンの発行時タイムスタンプ(Issued-AT)  |