这篇文章,我们将学习怎样初步集成sa-token来实现基本的登录认证和权限校验,并初步看下底层的源码实现。
sa-token是一个轻量级的登录鉴权框架,比之前的shiro和springsecurity都要轻量,一行代码就可以实现登录功能。具体文档可以看:sa-token官网。这里我们不再赘述。直接来看具体实现代码。
点击看项目源码
cn.dev33 sa-token-spring-boot-starter 1.34.0 cn.dev33 sa-token-dao-redis-jackson 1.34.0 org.apache.commons commons-pool2
其中,如果是springboot3.x版本的话,上面的sa-token-spring-boot-starter依赖要换成sa-token-spring-boot3-starter
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new SaInterceptor(handler -> {SaRouter.match("/**", StpUtil::checkLogin).notMatch(SaTokenConstant.excludePathPatterns);// 这里可以给每个接口地址的路径鉴权,当然,也可以用注解在每个接口上鉴权})).addPathPatterns(SaTokenConstant.allRouters).excludePathPatterns(SaTokenConstant.excludePathPatterns);}@Bean@Primarypublic SaTokenConfig getSaTokenConfigPrimary(){SaTokenConfig config = new SaTokenConfig();// token名称config.setTokenName(SaTokenConstant.TOKEN_NAME);// token有效期,单位 秒,默认是30天config.setTimeout(30 * 24 * 60 * 60);// token无操作存活时间(指定时间内无操作就视为token过期) 单位:秒config.setActivityTimeout(SaTokenConstant.ACTIVITY_TIMEOUT);// 是否允许同一账号并发登录,true是允许多个同一账号一起登录,false时同一账号新登录的会挤掉旧登录的config.setIsConcurrent(false);// 在多人登录同一账号时,是否共用一个tokenconfig.setIsShare(true);// token风格config.setTokenStyle(TokenStyleEnum.SIMPLE_UUID.getTokenStyle());// 是否输出操作日志config.setIsLog(false);/*是否尝试从 cookie 里读取 Token,此值为 false 后,StpUtil.login(id) 登录时也不会再往前端注入Cookie,false时,可以返回给前端token,然后让前端在每次请求时header里携带返回的token,这样可以用于不能使用cookie的设备端,不在局限于web浏览器端*/config.setIsReadCookie(false);return config;}
}
/*** 全局异常处理*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {/*** 全局登录异常处理* @param exception 登录抛出的异常* @return 提示信息*/@ExceptionHandler(NotLoginException.class)public BaseResult
这里我们模拟下真实的登录场景:
用户在登录页一般会看到三个输入框,分别是:账号、密码和验证码图片。那么我们首先要在登录页面获取到验证码图片,并在登录接口中传入验证码并进行校验,那么后端就必须要存下返回给前端的验证码,那么我们需要先返回前端一个临时token,然后前端拿着这个临时token来请求获取验证码的接口,后端拿到这个临时token后,生成验证码,并将这个临时token作为缓存的key,验证码内容作为value存入缓存中。等到前端调用真正的登录接口时,需要传入 账号、密码、验证码和临时token,在这个接口中,我们通过临时token,去缓存中拿到验证码,和前端传入的验证码进行比对,然后再校验账号和密码。
@RestController
@Slf4j
public class LoginController {@Resourceprivate VerifyImgUtil verifyImgUtil;@Resourceprivate LoginService loginService;/*** 在获取验证码接口和登录接口前,先调用,获取一个临时token,用来作为验证码的key** @return*/@GetMapping("tempToken")public BaseResult getTempToken(@RequestParam("tokenKey") String tokenKey) {log.info("开始获取临时token------>" + tokenKey);String token = SaTempUtil.createToken(tokenKey, 5 * 60);loginService.saveTempToken(token);return BaseResult.success(token, null);}/*** 获取图片验证码** @param tempToken 获取到的临时token* @param response*/@GetMapping("getVerifyImg")public BaseResult getVerifyImg(@RequestParam("tempToken") String tempToken, HttpServletResponse response) throws IOException {log.info("开始获取图片验证码------>" + tempToken);if (!loginService.checkTempToken(tempToken)) {return BaseResult.fail(530, LoginConstant.TOKEN_EMPTY);}response.setContentType("image/jpeg");//设置相应类型,告诉浏览器输出的内容为图片response.setHeader("Pragma", "No-cache");//设置响应头信息,告诉浏览器不要缓存此内容response.setHeader("Cache-Control", "no-cache");response.setDateHeader("Expires", 0);OutputStream os = response.getOutputStream();verifyImgUtil.lineCaptcha(os, tempToken);try {os.flush();} finally {os.close();}return null;}/*** 模拟登录接口,真实场景中,可以先提供一个接口,返回一个临时token,* 然后再提供一个获取验证码的接口,里面需要传入刚才返回的临时token,生成验证码后,以临时token作为key,验证码作为value放入缓存,* 然后再来调用这个正式登录接口,里面需要传验证码和临时token以及用户的账号密码,先根据临时token从缓存中获取验证码来比对,* 然后再比对账号密码,比对成功后,删除缓存中对应临时token的验证码数据,并删除临时token** @param dto 登录数据* @return*/@PostMapping("/login")public BaseResult login(@RequestBody LoginDto dto) {log.info("开始登录------>" + JSON.toJSONString(dto));BaseResult result = loginService.checkLogin(dto);if (!result.isState()) {return result;}loginService.deleteTempToken(dto.getTempToken());// 校验成功后移除临时token// 登录StpUtil.login("10001");// 将用户的数据放入上下文session中StpUtil.getSession().set(LoginConstant.LOGIN_NAME, "admin");StpUtil.getSession().set(LoginConstant.REAL_NAME, "管理员");StpUtil.getSession().set(LoginConstant.USER_ID, "10001");return BaseResult.success(StpUtil.getTokenValue(), "登录成功");}/*** 注销登录接口*/@PostMapping("logout")public void logout(){StpUtil.logout();}
}
public interface LoginService {/*** 登录校验* @param dto* @return*/BaseResult checkLogin(LoginDto dto);/*** 存放临时token* @param tempToken 临时token*/void saveTempToken(String tempToken);/*** 删除临时token* @param tempToken 临时token*/void deleteTempToken(String tempToken);/*** 校验临时token是否有效* @param tempToken 临时token* @return*/boolean checkTempToken(String tempToken);
}
@Service
public class LoginServiceImpl implements LoginService {@Resourceprivate RedisUtil redisUtil;private static final String tempTokenPre = "tempToken";@Overridepublic BaseResult checkLogin(LoginDto dto) {if (StringUtils.isBlank(dto.getTempToken())) {return BaseResult.fail(530, LoginConstant.TOKEN_EMPTY);}if (StringUtils.isBlank(dto.getVerifyCode())) {return BaseResult.fail(530, LoginConstant.VERIFY_CODE_EMPTY);}if (StringUtils.isBlank(dto.getLoginName())) {return BaseResult.fail(530, LoginConstant.LOGIN_NAME_EMPTY);}if (StringUtils.isBlank(dto.getPassword())) {return BaseResult.fail(530, LoginConstant.PASSWORD_EMPTY);}// 校验Object verifyCode = redisUtil.get(dto.getTempToken());if (verifyCode == null) {return BaseResult.fail(530, LoginConstant.VERIFY_OVERDUE);}if (!dto.getVerifyCode().equalsIgnoreCase(verifyCode.toString())) {return BaseResult.fail(530, LoginConstant.VERIFY_CODE_ERROR);}if (!"admin".equals(dto.getLoginName())) {return BaseResult.fail(530, LoginConstant.LOGIN_NAME_ERROR);}if (!"123".equals(dto.getPassword())) {return BaseResult.fail(530, LoginConstant.PASSWORD_ERROR);}// 校验成功后,移除redisredisUtil.delete(dto.getTempToken());return BaseResult.success(null, null);}@Overridepublic void saveTempToken(String tempToken) {redisUtil.set(tempTokenPre + tempToken, "1", 300);}@Overridepublic void deleteTempToken(String tempToken) {redisUtil.delete(tempTokenPre + tempToken);}@Overridepublic boolean checkTempToken(String tempToken) {Object o = redisUtil.get(tempTokenPre + tempToken);return o != null;}
}
@Data
public class LoginDto implements Serializable {/*** 账号*/private String loginName;/*** 密码*/private String password;/*** 验证码*/private String verifyCode;/*** 临时token,用来校验验证码*/private String tempToken;
}
@Data
public class BaseResult implements Serializable {/*** 返回数据*/private T data;/*** 提示信息*/private String message;/*** 状态码*/private int code;/*** 状态*/private boolean state = true;public static BaseResult result(int code, String message, boolean state) {BaseResult result = new BaseResult<>();result.setCode(code);result.setMessage(message);result.setState(state);return result;}/*** 返回信息** @param code 状态码* @param message 信息* @param t 数据* @param T* @return ResultVo*/public static BaseResult result(int code, String message, T t, boolean state) {BaseResult r = new BaseResult<>();r.setCode(code);r.setMessage(message);r.setData(t);r.setState(state);return r;}/*** 返回成功** @param data data* @param T* @return ResultVo*/public static BaseResult success(T data, String message) {return result(HttpStatus.OK.value(), message, data, true);}/*** 返回失败** @param code code* @param T* @return ResultVo*/public static BaseResult fail(int code, String message) {return result(code, message, null, false);}
}
/*** 要排除登录校验的接口地址枚举类*/
@Getter
public enum ExcludePathEnum {TEMP_TOKEN("/tempToken", "获取临时token的接口"),VERIFY_IMG("/getVerifyImg", "获取验证码图片的接口"),LOGIN("/login", "登录接口"),LOGOUT("/logout", "注销登录接口");private final String path;private final String remark;ExcludePathEnum(String path, String remark) {this.path = path;this.remark = remark;}
}
public class LoginConstant {public static final String TOKEN_EMPTY = "token不能为空!";public static final String LOGIN_NAME_EMPTY = "请输入账号!";public static final String PASSWORD_EMPTY = "请输入密码!";public static final String LOGIN_NAME_ERROR = "账号不存在!";public static final String PASSWORD_ERROR = "密码错误,请重新输入!";public static final String VERIFY_CODE_EMPTY = "验证码不能为空!";public static final String VERIFY_CODE_ERROR = "验证码错误,请重新输入!";public static final String VERIFY_OVERDUE = "验证码已过期,请重新登录!";public static final String LOGIN_NAME = "loginName";public static final String REAL_NAME = "userName";public static final String USER_ID = "userId";
}
public class SaTokenConstant {/*** token的字段名*/public static final String TOKEN_NAME = "token";/*** token无操作存活时间(指定时间内无操作就视为token过期) 单位:秒*/public static final long ACTIVITY_TIMEOUT = 24 * 60 * 60;public static final List allRouters = Collections.singletonList("/**");public static final List excludePathPatterns = Arrays.asList("/test/**","/file/upload/img/head/**","/swagger-resources/**","/webjars/**","/v2/**","/doc.html","**/swagger-ui.html","/swagger-ui.html/**","/img/head/**",ExcludePathEnum.TEMP_TOKEN.getPath(),ExcludePathEnum.VERIFY_IMG.getPath(),ExcludePathEnum.LOGIN.getPath(),ExcludePathEnum.LOGOUT.getPath());public static final String NOT_TOKEN = "您未登录,请登录!";public static final String TOKEN_OVERDUE = "登录已失效,请重新登录!";public static final String LOGIN_REPLACE = "您的账号已在别处登录!";public static final String PERMISSION_ERROR = "您没有权限!";
}
@Getter
public enum TokenStyleEnum {/*** 一般的uuid风格*/UUID("uuid"),/*** uuid风格,只不过去掉了中划线*/SIMPLE_UUID("simple-uuid"),/*** 随机32位字符串*/RANDOM_32("random-32"),/*** 随机64位字符串*/RANDOM_64("random-64"),/*** 随机128位字符串*/RANDOM_128("random-128"),/*** tik风格,gr_SwoIN0MC1ewxHX_vfCW3BothWDZMMtx__*/TIK("tik");private final String tokenStyle;TokenStyleEnum(String tokenStyle) {this.tokenStyle = tokenStyle;}
}



这里获取到真正的token后,前端就要在之后的接口中将这个token放入header中,至于这个header的字段名叫什么,在
这里的配置文件中会配置,配置后,接口的登录校验就会从header中获取这个字段的值来进行登录校验
已经登录后的调用返回:

未登录的调用返回:
