blog

SpringBoot入門: JWTベースのシンプルな認証と認可

このプロジェクトに関与するプロジェクトの大半の認証と認可は、Spring SecurityでJWTを使用します。 その中で、@ConfigurationPropertiesアノテーションを介して、上記...

Apr 27, 2020 · 10 min. read
シェア

pom.xmlの依存関係設定に追加します:

<dependency>
 <groupId>io.jsonwebtoken</groupId>
 <artifactId>jjwt</artifactId>
 <version>0.9.0</version>
</dependency>
<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
 <groupId>jakarta.xml.bind</groupId>
 <artifactId>jakarta.xml.bind-api</artifactId>
</dependency>

JWTの設定

最初にしなければならないことは、application.ymlファイルにjwtの設定を追加することです:

# application.yml
jwt:
 issue: wxbox
 token-header: Authorization
 token-prefix: 'Bearer '
 expiration: 604800

# application-dev.yml
jwt:
 secret: 1048c08c3a502d78feex2b59ce243342

# application-prod.yml
jwt:
 secret: 1048c08c3a502d78feex2b59ce243342

次にJWTツールクラスを作成します:

package com.foxescap.wxbox.common;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.Date;
import java.util.function.Function;

/**
 * @author xfly
 */
@Data
@ConfigurationProperties(prefix = "jwt")
@Component
public class JwtUtil {

 private String tokenHeader;

 private String tokenPrefix;

 private String issuer;

 private String secret;

 private Long expiration;

 /**
 * トークンを作成する
 * @param userDetails ユーザー情報
 * @return token
 */
 public String createToken(UserDetails userDetails) {
 final Date issuedAt = new Date();

 var roles = new ArrayList<String>();
 for (var role : userDetails.getAuthorities()) {
 roles.add(role.getAuthority());
 }

 return Jwts.builder()
 .setHeaderParam("typ", "JWT")
 .signWith(SignatureAlgorithm.HS256, secret)
 .claim("rol", String.join(",", roles))
 .setIssuer(issuer)
 .setIssuedAt(issuedAt)
 .setSubject(userDetails.getUsername())
 .setExpiration(new Date(issuedAt.getTime() + expiration * 1000))
 .compact();
 }

 /**
 * トークンの有効期限が切れているかどうかを判断する
 * @param token token
 * @return true-  false- 
 */
 public boolean isTokenExpired(String token) {
 final Date expiration = getExpirationFromToken(token);

 return expiration.before(new Date());
 }

 /**
 * トークンが正当かどうかを判断する
 * @param token token
 * @param userDetails ユーザー情報
 * @return true-  false- 
 */
 public Boolean validateToken(String token, UserDetails userDetails) {
 final String username = getUsernameFromToken(token);

 return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
 }

 /**
 * トークンからユーザー名を取得する
 * @param token token
 * @return  
 */
 public String getUsernameFromToken(String token) {
 return getClaimFromToken(token, Claims::getSubject);
 }

 /**
 * トークンから有効期限を取得する
 * @param token token
 * @return 有効期限
 */
 public Date getExpirationFromToken(String token) {
 return getClaimFromToken(token, Claims::getExpiration);
 }

 /**
 * トークンを分解し、必要なパーツを手に入れる
 * @param token token
 * @param claimsResolver 必要な部品へのアクセス方法
 * @param <T> T
 * @return 希望部分
 */
 private <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {

 Claims claims = Jwts.parser()
 .setSigningKey(secret)
 .parseClaimsJws(token)
 .getBody();

 return claimsResolver.apply(claims);
 }
}

ここで、上記の構成情報は、 @ConfigurationProperties(prefix = "jwt") アノテーションを介して、適切なプロパティに自動的に入力されます。

UserDetailsインターフェイスの実装

一般的に、いくつかのロジックをカスタマイズするには、UserDetailsインターフェイスを実装する必要があります:

package com.foxescap.wxbox.model;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

/**
 * @author xfly
 */
@Data
public class Admin implements UserDetails {

 @TableId(type = IdType.AUTO)
 private Long id;

 private String username;

 private String password;

 private String role;

 private String regIp;

 private String loginIp;

 private LocalDateTime loginAt;

 private Integer status;

 @TableField(fill = FieldFill.INSERT)
 private LocalDateTime createdAt;

 @Override
 public Collection<? extends GrantedAuthority> getAuthorities() {
 List<SimpleGrantedAuthority> authorities = new ArrayList<>();
 authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()));

 return authorities;
 }

 @Override
 public boolean isAccountNonExpired() {
 return true;
 }

 @Override
 public boolean isAccountNonLocked() {
 return true;
 }

 @Override
 public boolean isCredentialsNonExpired() {
 return true;
 }

 @Override
 public boolean isEnabled() {
 return status == 1;
 }
}

Lombok の @Data アノテーションが使用されていることに注意しましょう。もし使用されていない場合は、 getUsername() メソッドと getPassword() メソッドをオーバーライドする必要があります。

UserDetailService インターフェースの実装

このインターフェイスには、AdminService で実装できる抽象メソッド loadUserByUsername() が 1 つだけあります:

@Override
public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
 var admin = lambdaQuery().eq(Admin::getUsername, s).one();
 if (admin != null) {
 return admin;
 }
 throw new UsernameNotFoundException("User not found with username: " + s);
}

認証フィルターの実装

package com.foxescap.wxbox.filter;

import com.foxescap.wxbox.common.ApiResponse;
import com.foxescap.wxbox.common.JwtUtil;
import com.foxescap.wxbox.service.AdminService;
import io.jsonwebtoken.JwtException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author xfly
 */
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

 private final JwtUtil jwtUtil;

 private final AdminService adminService;

 public JwtAuthenticationFilter(JwtUtil jwtUtil, AdminService adminService) {
 this.jwtUtil = jwtUtil;
 this.adminService = adminService;
 }

 @Override
 protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
 String authTokenHeader = request.getHeader(jwtUtil.getTokenHeader());
 String token;
 String username;

 if (authTokenHeader == null || !authTokenHeader.startsWith(jwtUtil.getTokenPrefix())) {
 SecurityContextHolder.clearContext();
 } else {
 token = authTokenHeader.replaceAll(jwtUtil.getTokenPrefix(), "");
 try {
 username = jwtUtil.getUsernameFromToken(token);
 UserDetails userDetails = adminService.loadUserByUsername(username);
 if (jwtUtil.validateToken(token, userDetails)) {
 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
 usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
 SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
 }
 } catch (JwtException e) {
 ApiResponse.fail(response, e.getMessage());
 return;
 }
 }

 chain.doFilter(request, response);
 }
}

ApiResponse.failメソッドの1つは以下の通りです:

/**
 * 未返却
 * @param response HttpServletResponse
 * @param msg  
 * @throws IOException IOException
 */
public static void fail(HttpServletResponse response, String msg) throws IOException {
 response.setContentType("application/json; charset=utf-8");
 response.setCharacterEncoding("UTF-8");
 var out = response.getOutputStream();
 out.write(new ObjectMapper().writer().writeValueAsString(ApiResponse.fail(400, msg)).getBytes(StandardCharsets.UTF_8));
 out.flush();
 out.close();
}
package com.foxescap.wxbox.config;

import com.foxescap.wxbox.filter.JwtAuthenticationFilter;
import com.foxescap.wxbox.service.AdminService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

/**
 * @author xfly
 */
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

 private final AdminService adminService;

 private final JwtAuthenticationFilter jwtAuthenticationFilter;

 public WebSecurityConfig(AdminService adminService, JwtAuthenticationFilter jwtAuthenticationFilter) {
 this.adminService = adminService;
 this.jwtAuthenticationFilter = jwtAuthenticationFilter;
 }

 @Override
 public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
 authenticationManagerBuilder.userDetailsService(adminService).passwordEncoder(passwordEncoder());
 }

 @Bean
 public PasswordEncoder passwordEncoder() {
 return new BCryptPasswordEncoder();
 }

 @Bean
 @Override
 public AuthenticationManager authenticationManagerBean() throws Exception {
 return super.authenticationManagerBean();
 }

 @Override
 protected void configure(HttpSecurity http) throws Exception {
 http.csrf().disable()
 .cors()
 .and()
 .authorizeRequests()
 .antMatchers("/admin/**").authenticated()
 .anyRequest().permitAll();

 http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
 }
}

ログイン・インターフェースの実装

package com.foxescap.wxbox.controller;

import com.foxescap.wxbox.common.ApiCode;
import com.foxescap.wxbox.common.ApiResponse;
import com.foxescap.wxbox.common.JwtUtil;
import com.foxescap.wxbox.dto.AdminInfoDto;
import com.foxescap.wxbox.dto.param.AdminLoginParam;
import com.foxescap.wxbox.service.AdminService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid;
import java.util.stream.Collectors;

/**
 * @author xfly
 */
@RestController
@Validated
public class AdminController {

 private final AuthenticationManager authenticationManager;

 private final AdminService adminService;

 private final JwtUtil jwtUtil;

 public AdminController(AuthenticationManager authenticationManager, AdminService adminService, JwtUtil jwtUtil) {
 this.authenticationManager = authenticationManager;
 this.adminService = adminService;
 this.jwtUtil = jwtUtil;
 }

 @PostMapping("/auth/admin")
 public ApiResponse<Object> login(@RequestBody @Valid AdminLoginParam param, HttpServletRequest request) {

 try {
 authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(param.getUsername(), param.getPassword()));
 } catch (BadCredentialsException e) {
 return ApiResponse.fail(ApiCode.API_USERNAME_PASSWORD_UNMATCHED);
 }

 UserDetails userDetails = adminService.loadUserByUsername(param.getUsername());

 adminService.login(userDetails.getUsername(), request.getRemoteAddr());

 String token = jwtUtil.createToken(userDetails);

 var data = new AdminInfoDto();
 data.setToken(token);
 data.setUsername(userDetails.getUsername());
 data.setRoles(userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()));
 return ApiResponse.success(data);
 }

 @GetMapping("/admin/info")
 public ApiResponse<AdminInfoDto> getInfo() {
 return ApiResponse.success(adminService.getInfo());
 }
}

例えば、上記のinfoインターフェースはまず認証される必要があり、認証に成功した後に初めて特定のビジネスロジックに入ります。ビジネスロジックの中で、現在ログインしているユーザーの情報を取得する必要がある場合、以下の方法で取得できます:

SecurityContextHolder.getContext().foo();

とりあえず、Spring Securityの基本的な機能の一部だけ使ってみました。

Read next

バックエンド・サーバ・プロキシとしてNginxを勧める理由

1.まえがき:実際のサーバは公共のネットワークに直接公開すべきではなく、そうでなければサーバに関する情報を公開する可能性が高いだけでなく、攻撃に対してより脆弱です。より "市民的 "な解決策は、Nginxを使ってリバースプロキシすることです。今日はNginxのリバースプロキシの機能について説明します。

Apr 27, 2020 · 6 min read