springboot+shiro+jwt实现基于token的无状态授权认证
这里不再讲shiro的原理和鉴权认证流程,网上一大堆,可以自行查阅。我们知道,原本的shiro是基于session来进行认证登录的,但现如今大多数都是前后端分离的,session不利于跨域,以及页面的跳转应该是由前端来控制,后端最好不参与页面的跳转,只返回所需的数据。所以,这里将对shiro禁用session,整合jwt实现无状态的token登录认证。话不多说,我们直接上代码。 最后会附上我的项目地址。
maven的配置
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
<version>1.9.1</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.19.2</version>
</dependency>
JWT工具类
@Slf4j
public class JWTUtil {
private static final long EXPIRE = 60 * 1000L;
private static final String SECRET = "jwt+shiro+heZhan";
private static final String USER_KEY = "userName";
public static String createBearerToken(String userName){
return "Bearer " + createToken(userName);
}
public static String createToken(String userName){
long current = System.currentTimeMillis();
Date date = new Date(current + EXPIRE);
Map<String, Object> map = new HashMap<>();
map.put("alg", "HS256");
map.put("typ", "JWT");
String token;
try {
token = JWT.create()
.withHeader(map)
.withClaim(USER_KEY, userName)
.withClaim("current", current)
.withExpiresAt(date)
.withIssuedAt(new Date(current))
.sign(Algorithm.HMAC256(SECRET));
} catch (Exception e){
log.error("为用户{}创建token失败", userName);
throw new RuntimeException("为用户创建token失败", e);
}
return token;
}
public static boolean verifyToken(String token) throws AuthenticationException {
try {
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
verifier.verify(token);
return true;
} catch (Exception e){
log.error("校验token={}失败", token, e);
throw e;
}
}
public static String getUserInfo(String token){
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim(USER_KEY).asString();
} catch (Exception e){
log.error("从token={}中获取用户信息失败", token, e);
return null;
}
}
public static boolean isExpire(String token) {
DecodedJWT jwt = JWT.decode(token);
return jwt.getExpiresAt().getTime() < System.currentTimeMillis();
}
}
密码加密工具类
因为用户输入账号密码后,我们在首页第一次登录需要匹配账号密码,但这里的密码是明文的,很不安全,所以这里我们对密码进行加密,shiro提供了加密的方法,我们这里直接调用。
public class EncryptionUtil {
public static String encryption(String password, String salt){
ByteSource byteSource = ByteSource.Util.bytes(salt);
SimpleHash result = new SimpleHash("SHA-1", password, byteSource, 2);
return result.toHex();
}
}
上面的SimpleHash类在new的时候传入了4个参数:
- 表明加密的算法,这里使用了"SHA-1"的加密算法;
- 要加密的数据,这里传入了用户的密码;
- 加密盐,这里使用账号作为加密盐,确保万一两个人使用了同样的密码,但是账号不同,加密后的数据也会不同;
- 加密次数。
最后返回16进制的加密后的字符串,因为在shiro中的密码匹配器中,默认返回的也是16进制的字符串。
首页使用账号密码登录
这里我们先模拟首页登录,一般首页都是输入账号密码来登录,然后后台这里,我们先去匹配用户的账号密码,匹配成功后,下发token,之后的请求,都是携带下发的token去请求。
自定义Realm类
@Component
@DependsOn("myHashedCredentialsMatcher")
public class MyRealm extends AuthorizingRealm {
@Resource
private UserService userService;
public MyRealm(MyHashedCredentialsMatcher myHashedCredentialsMatcher) {
super();
myHashedCredentialsMatcher.setHashAlgorithmName("SHA-1");
myHashedCredentialsMatcher.setHashIterations(2);
myHashedCredentialsMatcher.setStoredCredentialsHexEncoded(true);
this.setCredentialsMatcher(myHashedCredentialsMatcher);
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
User user = (User) SecurityUtils.getSubject().getPrincipal();
Set<String> permissions = new HashSet<>();
permissions.add("user:show");
permissions.add("user:admin");
permissions.add("user:add");
info.setStringPermissions(permissions);
return info;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) authenticationToken;
String userName = usernamePasswordToken.getUsername();
String password = new String(usernamePasswordToken.getPassword());
User user = userService.getUserByName(userName);
if (user == null || StringUtils.isBlank(userName) || StringUtils.isBlank(password)){
throw new AuthenticationException();
}
return new SimpleAuthenticationInfo(user, user.getPassword(), ByteSource.Util.bytes(user.getName()), getName());
}
@Override
public Class getAuthenticationTokenClass() {
return UsernamePasswordToken.class;
}
}
配置自定义的密码匹配器
@Slf4j
@Component
public class MyHashedCredentialsMatcher extends HashedCredentialsMatcher {
@Resource
private RedisUtil redisUtil;
@Resource
private UserService userService;
public static final String KEY_PREFIX = "shiro:cache:retryLimit:";
public static final Integer MAX = 5;
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token;
String userName = usernamePasswordToken.getUsername();
String key = KEY_PREFIX + userName;
AtomicInteger atomicInteger = (AtomicInteger) redisUtil.get(key);
if (atomicInteger == null){
atomicInteger = new AtomicInteger(0);
}
User user = userService.getUserByName(userName);
if (atomicInteger.incrementAndGet() > MAX){
if (user != null && user.getState() != 1){
user.setState(1);
userService.updateById(user);
}
log.info("锁定用户"+ userName);
throw new ExcessiveAttemptsException();
}
boolean matches = super.doCredentialsMatch(token, info);
if (matches){
redisUtil.delete(key);
if (user.getState() != 0){
userService.updateUserState(user.getId(), 0);
}
} else {
redisUtil.set(key, atomicInteger, 300);
}
return matches;
}
}
到这里,我们关于首页使用账号密码登录认证的主体代码已经写完,接下来还需要写一个shiroConfig的配置类去装配,这个先等我们写完接下来的token认证的代码再来写config类。
首页登录后的其他请求,基于token认证
自定义Realm类
@Component
@DependsOn("myJWTCredentialsMatcher")
public class JWTRealm extends AuthorizingRealm {
public JWTRealm(MyJWTCredentialsMatcher myJWTCredentialsMatcher) {
super(myJWTCredentialsMatcher);
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
User user = (User) SecurityUtils.getSubject().getPrincipal();
Set<String> permissions = new HashSet<>();
permissions.add("user:show");
permissions.add("user:admin");
permissions.add("user:add");
info.setStringPermissions(permissions);
return info;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
BearerToken bearerToken = (BearerToken) token;
String tokenString = bearerToken.getToken();
String userName = JWTUtil.getUserInfo(tokenString);
User user = new User();
user.setName(userName);
return new SimpleAuthenticationInfo(user, tokenString, getName());
}
@Override
public Class getAuthenticationTokenClass() {
return BearerToken.class;
}
}
自定义JWT过滤器
@Slf4j
public class JWTFilter extends BearerHttpAuthenticationFilter {
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
boolean res = false;
HttpServletRequest req = WebUtils.toHttp(request);
if (!isLoginAttempt(request, response)){
req.setAttribute("exception", new NotLoginException("未登录!"));
req.getRequestDispatcher("/api/loginError").forward(request, response);
return false;
}
BearerToken token = (BearerToken) createToken(request, response);
if (JWTUtil.isExpire(token.getToken())){
if (!refreshToken(request, response)){
req.getRequestDispatcher("/api/loginExpire").forward(request, response);
return false;
}
return true;
}
try {
res = super.onAccessDenied(request, response);
} catch (Exception e){
Throwable cause = e.getCause();
if (cause instanceof TokenExpiredException){
refreshToken(request, response);
} else {
throw e;
}
}
return res;
}
private boolean refreshToken(ServletRequest request, ServletResponse response){
log.info("刷新token...");
HttpServletRequest req= (HttpServletRequest) request;
String tokenHeader = req.getHeader(AUTHORIZATION_HEADER);
String[] tokens = tokenHeader.split(" ");
String token = tokens[1];
String userName = JWTUtil.getUserInfo(token);
String newToken = JWTUtil.createToken(userName);
BearerToken bearerToken = new BearerToken(newToken);
try {
getSubject(request, response).login(bearerToken);
HttpServletResponse res = (HttpServletResponse) response;
res.setHeader("Access-Control-Expose-Headers", "Authorization");
res.setHeader("Authorization", "Bearer " + newToken);
return true;
} catch (Exception e){
log.error("token刷新失败", e);
return false;
}
}
}
这里,我们本可以直接使用shiro自带的BearerHttpAuthenticationFilter来校验token,但这里有个问题,就是如果token过期后,它不会抛出异常,而是返回一个false,这里和token校验的其他错误一样,不会抛异常,只会返回false,所以我们需要写一个自定义的filter来重写它的认证方法,先判断token是否过期,再刷新下token。这里只是模拟,所以token过期了就直接刷新token,在企业里应用的话,还需要一个refreshToken,这个是可以在生成token的同时生成,并写在缓存里,刷新token的时候去校验下refreshToken。
自定义token匹配器
@Slf4j
@Component
public class MyJWTCredentialsMatcher implements CredentialsMatcher {
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws RuntimeException{
BearerToken bearerToken = (BearerToken) token;
String tokenString = bearerToken.getToken();
return JWTUtil.verifyToken(tokenString);
}
}
多Realm下的配置
因为这里是多个Realm类,原本在这里可以由Realm来自动注册装配的,然后由ModularRealmAuthenticator来自动管理的,但是这样的话,realm类去做认证鉴权时抛出的异常,在这里就会被捕获,而且不会抛出,如下图: 所以,我们需要自定义一个类,来继承ModularRealmAuthenticator并重写此方法:
@Slf4j
public class MyModularRealmAuthenticator extends ModularRealmAuthenticator {
@Override
protected AuthenticationInfo doMultiRealmAuthentication(Collection<Realm> realms, AuthenticationToken token) throws AuthenticationException {
AuthenticationStrategy strategy = getAuthenticationStrategy();
AuthenticationInfo aggregate = strategy.beforeAllAttempts(realms, token);
if (log.isTraceEnabled()) {
log.trace("Iterating through {} realms for PAM authentication", realms.size());
}
for (Realm realm : realms) {
try {
aggregate = strategy.beforeAttempt(realm, token, aggregate);
} catch (ShortCircuitIterationException shortCircuitSignal) {
break;
}
if (realm.supports(token)) {
log.trace("Attempting to authenticate token [{}] using realm [{}]", token, realm);
AuthenticationInfo info = null;
Throwable t = null;
info = realm.getAuthenticationInfo(token);
aggregate = strategy.afterAttempt(realm, token, info, aggregate, t);
} else {
log.debug("Realm [{}] does not support token {}. Skipping realm.", realm, token);
}
}
aggregate = strategy.afterAllAttempts(token, aggregate);
return aggregate;
}
}
这个类,会在下面要写的config中装配一下。
配置shiroConfig
配置shiro禁用session
public class MySubjectFactory extends DefaultWebSubjectFactory {
@Override
public Subject createSubject(SubjectContext context) {
context.setSessionCreationEnabled(false);
return super.createSubject(context);
}
}
public class MySubjectDAO extends DefaultSubjectDAO {
@Override
protected boolean isSessionStorageEnabled(Subject subject) {
return false;
}
}
总共是两个地方都要继承并重写,才能禁用session。
配置跨域的filter
@Slf4j
public class CorsFilter extends PathMatchingFilter {
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setHeader("Access-control-Allow-Origin",httpRequest.getHeader("Origin"));
httpResponse.setHeader("Access-control-Allow-Methods","GET,POST,OPTIONS,PUT,DELETE");
httpResponse.setHeader("Access-control-Allow-Headers",httpRequest.getHeader("Access-Control-Request-Headers"));
httpResponse.setHeader("Content-Type","application/json;charset=UTF-8");
if (httpRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
log.debug("收到一个OPTIONS请求--"+httpRequest.getRequestURI());
httpResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}
shiroConfig
@Configuration
public class ShiroConfig {
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager){
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
shiroFilterFactoryBean.setLoginUrl("/api/login");
Map<String, Filter> filterMap = new HashMap<>();
filterMap.put("cors", new CorsFilter());
filterMap.put("jwt", new JWTFilter());
shiroFilterFactoryBean.setFilters(filterMap);
Map<String, String> map = new LinkedHashMap<>();
map.put("/api/*", "anon");
map.put("/**", "jwt");
shiroFilterFactoryBean.setFilterChainDefinitionMap(map);
return shiroFilterFactoryBean;
}
@Bean
public SubjectDAO subjectDAO(){
return new MySubjectDAO();
}
@Bean
public SubjectFactory subjectFactory(){
return new MySubjectFactory();
}
@Bean
public Authorizer authorizer(){
return new ModularRealmAuthorizer();
}
@Bean
public Authenticator authenticator(){
return new MyModularRealmAuthenticator();
}
}
测试
首页登录controller
@RestController
@Slf4j
@RequestMapping("/api")
public class LoginController {
@GetMapping("/login")
public String frontPage(){
return "未登录,跳首页去登录";
}
@PostMapping("/login")
public String login(@RequestBody LoginInfo loginInfo, HttpServletResponse response) {
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken token = new UsernamePasswordToken();
token.setUsername(loginInfo.getUserName());
token.setPassword(loginInfo.getPassword().toCharArray());
try {
subject.login(token);
User user = (User) subject.getPrincipal();
String tokenString = JWTUtil.createBearerToken(user.getName());
response.setHeader(ConstantEnum.AUTHORIZATION.getValue(), tokenString);
} catch (LockedAccountException e){
subject.logout();
return "账号已被锁定,请联系管理员!";
} catch (UnknownAccountException e){
subject.logout();
return "未知账号!";
} catch (ExcessiveAttemptsException e){
subject.logout();
return "账号或密码错误次数过多!请5分钟后再登录!";
} catch (IncorrectCredentialsException e){
subject.logout();
return "密码不正确!";
} catch (AuthenticationException e){
subject.logout();
return "账号或密码不正确!";
}
if (subject.isAuthenticated()){
return "登录成功";
} else {
subject.logout();
return "登录失败";
}
}
@PostMapping("/logout")
public String logout(){
Subject subject = SecurityUtils.getSubject();
subject.logout();
return "退出登录!";
}
@GetMapping("/loginError")
public String loginError(HttpServletResponse response) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return "未登录!";
}
@GetMapping("/loginExpire")
public String loginExpire(){
return "token已过期,请重新登录!";
}
}
权限鉴权controller
@RestController
@Slf4j
@RequestMapping("/permission")
public class PermissionController {
@GetMapping("/show")
@RequiresPermissions("user:add")
public String showPermission(){
return "有权限看到此信息...";
}
@GetMapping("/showUnable")
@RequiresPermissions("user:update")
public String showPermissionUnable(){
return "有权限看到此信息...";
}
}
项目地址
项目地址:代码链接
|