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の基本的な機能の一部だけ使ってみました。