APIサーバとして作っているSpringBootアプリに認証の仕組みを入れようと思い、試行錯誤した内容をまとめておきます。
spring-securityを入れればよろしくやってくれるんじゃないの?と軽く思っていたのですが、思ったより長い道のりになってしまいました。
ID/PWでtokenを発行して、tokenを元にその他のAPIを呼び出せるか制御したい というのがやりたいことです。
いろいろ調べるとJWTという技術が使えそうだと判明して、「Implementing JWT Authentication on Spring Boot APIs」というサイトを参考に実装しています。
JWT(Json Web Token)とは
以下の3つのJSONデータを.で結合してできた文字列を、URLでも安全に送れるように暗号化したものです。これをtokenとして利用します。ジョットと読むらしいです
- Header 使用しているアルゴリズムをbase64エンコードして設定
{ "alg": "HS512"}
- Payload データ。今回のサンプルではユーザ名と有効期限がbase64エンコードして設定されている
{ "sub": "nyasba", "exp": 1516271467 }
- Signature サーバ側のsecretを元に作成した署名
token自体はサーバ側でステートを管理しているわけではないというのが特徴です。
詳細な仕組みを知りたい方はこのあたりも参照してください。
- JWT(JSON Web Token)を使った認証を試みる https://blog.kazu69.net/2016/07/30/authenticate_with_json_web_token/
- JWTについて簡単にまとめてみた https://hiyosi.tumblr.com/post/70073770678/
- RFC7519 https://tools.ietf.org/html/rfc7519
demo
まずはイメージをつかむために出来上がったもののデモです
token発行
レスポンスヘッダで返していますが、bodyで返すことも可能です
curl -v -X POST -d "{ \"loginId\" : \"nyasba\", \"pass\" : \"password\"}" -H "accept: application/json" -H "Content-Type: application/json" "http://localhost:8080/user/login"
< HTTP/1.1 200
< Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJueWFzYmEiLCJleHAiOjE1MTYyNzE0Njd9.LXCxgcrDW-gtwSnAus3nVAMBrhQitAxmDn4k7dMZ9u82PLqnw467xjpwk67oz93PNy80cNxfBg0LuVbehIih3w
tokenを利用したAPI呼び出し
リクエストヘッダでtokenを渡しています。
curl -X GET -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJueWFzYmEiLCJleHAiOjE1MTYyNzE0Njd9.LXCxgcrDW-gtwSnAus3nVAMBrhQitAxmDn4k7dMZ9u82PLqnw467xjpwk67oz93PNy80cNxfBg0LuVbehIih3w" "http://localhost:8080/private"
this is private for nyasba
もちろんヘッダを指定しないと拒否されます
curl -X GET "http://localhost:8080/private"
{"timestamp":1516242884788,"status":403,"error":"Forbidden","message":"Access Denied","path":"/private"}
作ったもの
全てソースはこちらに入っています。実際に実装する方はgithubの方を見ていただいた方がよいかと思います。
https://github.com/nyasba/spring-jwt-auth
build.gradle
Spring Securityに加えてjjwtを追加しています
compile('org.springframework.boot:spring-boot-starter-web')
compile('org.springframework.boot:spring-boot-starter-security')
compile('io.jsonwebtoken:jjwt:0.9.0')
Controller
/privateを認証が必要なAPIと想定しています。後ほど説明しますが、ログイン用のパスは作りません。
@RestController
public class SampleController {
@GetMapping(value = "/public")
public String publicApi() {
return "this is public";
}
@GetMapping(value = "/private")
public String privateApi() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// JWTAuthenticationFilter#successfulAuthenticationで設定したusernameを取り出す
String username = (String) (authentication.getPrincipal());
return "this is private for " + username;
}
}
WebSecurityConfig
WebSecurityConfigurerAdapter を拡張したConfigクラスで/publicやログイン画面などは認証不要とします。
全体の構成は理解するためにConfigの中で出てくるクラスの役割もまとめておきます。
クラス名 | 役割 |
---|---|
JWTAuthenticationFilter | 認証フィルター。login用のpathでアクセスされた際にID/PWを取り出してトークンを発行してresponseヘッダにセットする |
JWTAuthorizationFilter | 認可フィルター。ヘッダにあるtokenを取り出し、tokenを検証する |
UserDetailsService | ログインユーザを検索する |
BCryptPasswordEncoder | パスワードを暗号化する |
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and().authorizeRequests()
.antMatchers("/public", SIGNUP_URL, LOGIN_URL).permitAll()
.anyRequest().authenticated()
.and().logout()
.and().csrf().disable()
.addFilter(new JWTAuthenticationFilter(authenticationManager(), bCryptPasswordEncoder()))
.addFilter(new JWTAuthorizationFilter(authenticationManager()))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
;
}
@Autowired
public void configureAuth(AuthenticationManagerBuilder auth) throws Exception {
auth
.userDetailsService(userDetailsService)
.passwordEncoder(bCryptPasswordEncoder());
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
JWTAuthenticationFilter (認証フィルター)
UsernamePasswordAuthenticationFilter のソースを読めばすぐわかりますが、デフォルトでは/loginに対してPOSTメソッドでusername,passwordというキーワードでログイン認証を行う仕様になっています。それをカスタマイズしています。
/loginでアクセスされた場合はこのフィルターで処理されるため、Controllerは用意しても動きません。ここは挙動を理解するのがすごく難しかったところでした。
public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(JWTAuthenticationFilter.class);
private AuthenticationManager authenticationManager;
private BCryptPasswordEncoder bCryptPasswordEncoder;
public JWTAuthenticationFilter(AuthenticationManager authenticationManager, BCryptPasswordEncoder bCryptPasswordEncoder) {
this.authenticationManager = authenticationManager;
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
// ログイン用のpathを変更する
setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(LOGIN_URL, "POST"));
// ログイン用のID/PWのパラメータ名を変更する
setUsernameParameter(LOGIN_ID);
setPasswordParameter(PASSWORD);
}
// 認証の処理
@Override
public Authentication attemptAuthentication(HttpServletRequest req,
HttpServletResponse res) throws AuthenticationException {
try {
// requestパラメータからユーザ情報を読み取る
UserForm userForm = new ObjectMapper().readValue(req.getInputStream(), UserForm.class);
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
userForm.getLoginId(),
userForm.getPass(),
new ArrayList<>())
);
} catch (IOException e) {
LOGGER.error(e.getMessage());
throw new RuntimeException(e);
}
}
// 認証に成功した場合の処理
@Override
protected void successfulAuthentication(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain,
Authentication auth) throws IOException, ServletException {
// loginIdからtokenを設定してヘッダにセットする
String token = Jwts.builder()
.setSubject(((User)auth.getPrincipal()).getUsername()) // usernameだけを設定する
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS512, SECRET.getBytes())
.compact();
res.addHeader(HEADER_STRING, TOKEN_PREFIX + token);
// ここでレスポンスを組み立てると個別のパラメータを返せるがFilterの責務の範囲内で実施しなければならない
// auth.getPrincipal()で取得できるUserDetailsは自分で作ったEntityクラスにもできるのでカスタム属性は追加可能
}
}
JWTAuthorizationFilter (認可フィルター)
リクエストにAuthorizationヘッダがあった場合はtokenを検証する
public class JWTAuthorizationFilter extends BasicAuthenticationFilter {
private AuthenticationManager authenticationManager;
public JWTAuthorizationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain) throws IOException, ServletException {
String header = req.getHeader(HEADER_STRING);
if (header == null || !header.startsWith(TOKEN_PREFIX)) {
chain.doFilter(req, res);
return;
}
// AuthorizationヘッダのBearer Prefixである場合
UsernamePasswordAuthenticationToken authentication = getAuthentication(req);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(req, res);
}
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
String token = request.getHeader(HEADER_STRING);
if (token != null) {
// parse the token.
String user = Jwts.parser()
.setSigningKey(SECRET.getBytes())
.parseClaimsJws(token.replace(TOKEN_PREFIX, ""))
.getBody()
.getSubject();
if (user != null) {
return new UsernamePasswordAuthenticationToken(user, null, new ArrayList<>());
}
return null;
}
return null;
}
}
UserDetailsService
ここはダミーです。本来はDB参照などを実施するかと思います。
どこで設定するパスワードが暗号化されているべきなのかが理解しづらかったので、その参考にしていただければ。
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private static List<String> usernameList = Arrays.asList("nyasba", "admin");
private static String ENCRYPTED_PASSWORD = "$2a$10$5DF/j5hHnbeHyh85/0Bdzu1HV1KyJKZRt2GhpsfzQ8387A/9duSuq"; // "password"を暗号化した値
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 本来ならここでDBなどからユーザを検索することになるが、サンプルのためリストに含まれるかで判定している
if(!usernameList.contains(username)){
throw new UsernameNotFoundException(username);
}
return User.withUsername(username)
.password(ENCRYPTED_PASSWORD)
.authorities("ROLE_USER") // ユーザの権限
.build();
}
}
所感
spring securityが全部やってくれると思ってたところから考えると大変でしたが、中身が理解できるとシンプルに思えます。参考にした記事を読み込んでいるときはどこがどうなっているんだと混乱していたのですが不思議ですw
Passwordという中で生のパスワードと暗号化されたパスワードが混ざっている、getPrincipalで取れるオブジェクトがUserだったりusernameのStringだったりするのがわかりにくい、などが課題かなと思います。