在系统中最基础的就是用户登陆,一般系统用到的登陆有2种,分别是账号密码和手机验证码。本笔记以账号密码登陆为例,手机验证码登陆等整个框架基本搭好后再写。
用户使用一个系统其实就是访问系统里的每一个功能,在浏览器中对应的就是一个个网页。一个正常的系统,那么肯定存在有些网页不需要权限,所有人都能访问,例如首页,登录页面,找回密码页面等;有些页面则需要用户登陆后才能访问,例如管理页面等。那么这就需要给用户授权,当然根据项目的实际需求,授权也分为功能授权和数据授权,这2块就比较复杂了,以后有机会再说,我这里只做登陆授权的案例。
单点登陆其实就是只要登陆一次可以无缝访问多个相关子项目。具体的含义自行百度。
第三方认证技术方案最主要是解决认证协议的通用标准 问题,因为要实现 跨系统认证,各系统之间要遵循一定的接口协议。目前应用比较广泛的是Qauth2认证。
本案例认证服务基于Spring Security Oauth2进行构建,并在其基础上作了一些扩展,采用JWT令牌机制,并自定义了用户身份信息的内容。 关于Spring Security 、Oauth2、JWT 可以网上搜索资料,这里就不展开了。
我这里简单说下认证服务的逻辑,
1、用户填写用户名、密码提交到后台,后端按照Spring Security安全框架标准去判断用户名密码是否正确。
2、用户名密码填写正确则根据项目的实际情况做相应的业务处理,例如简单一点的就是获取用户的基本信息,复杂的就需要获取用户的角色权限和数据权限等。这些数据保存到Redis中。
3、在配置文件中设置登陆时效,并且通过定义一种标准来更新登陆时效,类似于最早的用session作为登陆权限判断时自带的过期时间。
4、同一个账号,同时只能登陆一个终端,后登录的会踢掉之前登陆者。
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
<exclusions>
<exclusion>
<groupId>io.lettucegroupId>
<artifactId>lettuce-coreartifactId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>redis.clientsgroupId>
<artifactId>jedisartifactId>
dependency>
application.yml
注意:为了简便,下面的配置中我将druid删除了,有需要的查看《笔记三》
spring:
datasource:
name: test
url: jdbc:mysql://localhost:3306/test?useUnicode=true&serverTimezone=GMT&characterEncoding=utf-8&allowMultiQueries=true&useSSL=false
username: root
password: 123456
# 使用druid数据源
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
redis:
database: 1
host: 127.0.0.1
port: 6379
password: 123456
timeout: 5000
jedis:
pool:
max-active: 500
max-wait: 5000
max-idle: 400
min-idle: 50
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(final RedisConnectionFactory factory) {
final Jackson2JsonRedisSerializer<Object> j = new Jackson2JsonRedisSerializer<Object>(Object.class);
// value序列化
final RedisTemplate<String, Object> redisTemplate = new RedisTemplate<String, Object>();
redisTemplate.setValueSerializer(j);
redisTemplate.setHashValueSerializer(j);
// key序列化;(不然会出现乱码)
RedisSerializer<?> stringSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringSerializer);
redisTemplate.setHashKeySerializer(stringSerializer);
redisTemplate.setConnectionFactory(factory);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
可以参考前面的文章,限于篇幅,就不多说了。
《Springboot整合Redis(三) : 整合Redis》
《springboot集成redis高并发下同时保存获取字符串和对象时出现异常的解决办法》
@SpringBootTest(classes = { DemoApplication.class })
class DemoApplicationTests {
@Autowired
HnRedisUtils hnRedisUtils;
@Test
void add() {
hnRedisUtils.set("username", "半路凉亭");
}
}
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
本案例是通过账号密码方式做登陆认证的,而spring security框架本身就自带密码认证功能,只要按照框架的认证标准创建相应的步骤即可完成认证操作,非常方便。当然,在现实项目中可能除了需要用密码登陆外,还需要用手机验证码登陆,这块要按照spring security框架的结构自定义一些模块即可,后期有机会再写。
一个项目的标准化登陆有以下几项设置:不用认证的页面、登陆的失效时间、是否可以多终端登陆等。
application.yml
token:
expire:
seconds: 7200 #登录失效时长 单位:秒
jwtSecret: H@+N#-M*$S*7TA*R
login:
isMulti: true #是否可以多点登录
passwordParameter: password #修改登录的密码框名称,默认是password
loginHTML: /login.html #登陆页面地址
loginProcessingUrl: /login #登录处理地址,只是别名
permitAllUrl: /, /*.html, /login/**, /druid/** #不做登录拦截的地址
目前登陆认证使用比较广泛的是口令方式,运用比较多的就是oauth2+JWT,这块具体的原理可以看下面的文章,
《JSON Web Token 入门教程》
我们新建一个口令的类,目前只有一个属性,就是token
public class Token implements Serializable {
private static final long serialVersionUID = -9149471519045934216L;
private String token;
public Token() {
super();
}
public Token(final String token) {
super();
this.token = token;
}
public String getToken() {
return this.token;
}
public void setToken(final String token) {
this.token = token;
}
}
创建登陆的用户对象,并且实现spring security 的接口 UserDetails
public class CurrentLoginUser implements UserDetails {
private static final long serialVersionUID = -6135362231034146981L;
private String id;
private String username;
private String realname;
private String password;
private String redisKey;
/** 登陆时间戳(毫秒) */
private Long loginTime;
/** 过期时间戳 */
private Long expireTime;
private Boolean enabled;
private Boolean credentialsNonExpired;
private Boolean accountNonLocked;
private Boolean accountNonExpired;
/**登录状态 normalLogin :正常 ; kickOut : 被踢 */
private String loginType;
private String isAdmin;
//private String orgId;
private Map<String, Object> paramsMap;
}
说明:
username\password 是登陆的用户名和密码,spring security 会使用,此用户对象会写入redis中,安全考虑,再写入前将密码设为NULL。除了这2个字段外,其余字段理论上都不是必须的。
realname :登陆用户的真实姓名。
redisKey: 登陆成功后,当前用户在redis中对应的key,这个也是生成JWT的关键。
loginTime、expireTime :这2个时间就是有效期,根据具体的算法和配置有相应的改变,例如我一般是设置成登陆后有2个小时有效期,时间过了一半且未超过2小时,用户还在操作,那么有效期自动延长2小时,登陆的总有效时长可以在配置文件中设置,即上面的seconds: 7200。
isAdmin: 是否是超级管理员,在实际项目中,可用于超管权限,无视权限配置,特殊的一个字段,跟具体的业务关联。
paramsMap:额外的参数,1.具体的业务可能有额外的登陆需求 2.手机验证登陆使用。
登陆操作成功后常规分为创建口令即token ,刷新token, 删除token,通过token查询登陆用户信息等,所以这里创建一个token的操作层,直接给代码。
定义一个接口
public interface IHnTokenService {
/**
* 生成token
*
* @author huangmin
* @param loginUser
* @return
*/
Token saveToken(CurrentLoginUser loginUser);
/**
* 刷新token
*
* @author huangmin
* @param loginUser
*/
void refresh(CurrentLoginUser loginUser);
/**
* 通过token获取当前登陆用户
*
* @author huangmin
* @param token
* @return
*/
CurrentLoginUser getCurrentUser(String token);
CurrentLoginUser getCurrentUser(HttpServletRequest request);
/**
* 删除TOKEN并将redis的缓存删除
*
* @author huangmin
* @param token
* @return
*/
boolean deleteToken(String token);
}
实现类:
@Primary
@Service
public class HnTokenJWTServiceImpl implements IHnTokenService {
/**
* token过期秒数
*/
@Value("${token.expire.seconds}")
private Integer expireSeconds;
/**
* 私钥
*/
@Value("${token.jwtSecret}")
private String jwtSecret;
/**
* 是否可以多点登录
*/
@Value("${login.isMulti}")
private Boolean isMulti;
@Autowired
private HnRedisUtils redisUtils;
private static Key KEY = null;
private static final String LOGIN_USER_KEY = "LOGIN_USER_KEY";
@Override
public Token saveToken(final CurrentLoginUser loginUser) {
loginUser.setRedisKey(UUID.randomUUID().toString());
this.cacheLoginUser(loginUser);
final String jwtToken = this.createJWTToken(loginUser);
return new Token(jwtToken);
}
/**
* 生成jwt
*
* @param loginUser
* @return
*/
private String createJWTToken(final CurrentLoginUser loginUser) {
final Map<String, Object> claims = new HashMap<>();
claims.put(HnTokenJWTServiceImpl.LOGIN_USER_KEY, loginUser.getRedisKey());// 放入一个随机字符串,通过该串可找到登陆用户
final String jwtToken = Jwts.builder().setClaims(claims)
.signWith(SignatureAlgorithm.HS256, this.getKeyInstance()).compact();
return jwtToken;
}
/**
* 将loginUser 缓存到Redis
*
* @author huangmin
* @param loginUser
*/
private void cacheLoginUser(final CurrentLoginUser loginUser) {
final long currentTimeMillis = System.currentTimeMillis();
final Long loginUserTime = loginUser.getLoginTime();
if (loginUserTime == null) {
loginUser.setLoginTime(currentTimeMillis);
}
loginUser.setExpireTime(currentTimeMillis + this.expireSeconds * 1000);
// 根据uuid将loginUser缓存
this.redisUtils.set(this.getTokenKey(loginUser.getRedisKey()), loginUser);
// loginId:id
final String key = this.getLoginIdKey(loginUser.getId());
Set<String> set = new HashSet<String>();
// 获取的key存在
if (this.redisUtils.containsKey(key)) {
set = this.redisUtils.getObject(key, Set.class);
}
// 不能多点登录,将所有以前登录存入的KEY全部踢掉
// 将key组成一个SET放到以用户ID为KEY的变量里,并存入Redis
// 将所有以前登陆时存入的KEY全部踢掉
if (!this.isMulti) {
for (final Object object : set) {
final String redisKey = String.valueOf(object);
if (HnStringUtils.equals(redisKey, loginUser.getRedisKey())) {
continue;
}
// 将其余登录的账号全部踢出
final String reKey = this.getTokenKey(redisKey);
final CurrentLoginUser user = this.redisUtils.getObject(reKey, CurrentLoginUser.class);
if (user == null) {
continue;
}
user.setLoginType("kickOut");
this.redisUtils.set(reKey, user);
}
set = new HashSet<String>();
}
set.add(loginUser.getRedisKey());
this.redisUtils.set(key, set);
}
/**
* 更新缓存的用户信息
*/
@Override
public void refresh(final CurrentLoginUser loginUser) {
this.cacheLoginUser(loginUser);
}
@Override
public CurrentLoginUser getCurrentUser(final String jwtToken) {
try {
final String key = this.getReditKeyFromJWTToken(jwtToken);
if (HnStringUtils.isNotBlank(key)) {
return this.redisUtils.getObject(this.getTokenKey(key), CurrentLoginUser.class);
}
} catch (final Exception e) {
e.printStackTrace();
throw new HnException(e);
}
return null;
}
@Override
public CurrentLoginUser getCurrentUser(final HttpServletRequest request) {
final String jwtToken = TokenFilter.getToken(request);
return this.getCurrentUser(jwtToken);
}
@Override
public boolean deleteToken(final String jwtToken) {
final String redisKey = this.getReditKeyFromJWTToken(jwtToken);
if (HnStringUtils.isNotBlank(redisKey)) {
final String key = this.getTokenKey(redisKey);
final CurrentLoginUser loginUser = this.redisUtils.getObject(key, CurrentLoginUser.class);
this.redisUtils.delete(key);
if (loginUser != null) {
return true;
}
}
return false;
}
/**
* 通过jwt获取redis中的token的ID
*
* @author huangmin
* @date 2021年8月6日
* @param jwtToken
* @return
*/
public String getReditKeyFromJWTToken(final String jwtToken) {
if (HnStringUtils.isBlankOrNULL(jwtToken)) {
return null;
}
try {
final Map<String, Object> jwtClaims = Jwts.parser().setSigningKey(this.getKeyInstance())
.parseClaimsJws(jwtToken).getBody();
return String.valueOf(jwtClaims.get(HnTokenJWTServiceImpl.LOGIN_USER_KEY));
} catch (final Exception e) {
throw new HnException("口令已过期");
}
}
private Key getKeyInstance() {
if (HnTokenJWTServiceImpl.KEY == null) {
synchronized (HnTokenJWTServiceImpl.class) {
if (HnTokenJWTServiceImpl.KEY == null) {// 双重锁
final byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(this.jwtSecret);
HnTokenJWTServiceImpl.KEY = new SecretKeySpec(apiKeySecretBytes,
SignatureAlgorithm.HS256.getJcaName());
}
}
}
return HnTokenJWTServiceImpl.KEY;
}
private String getLoginIdKey(final String loginId) {
return "loginId:" + loginId;
}
private String getTokenKey(final String token) {
return "tokens:" + token;
}
}
说明:每一个用户登陆成功后会在redis中写入至少2条信息,其中一条是以自身ID为KEY的数据,保存的值就是对应的用于生成JWT的随机码,这里用的是UUID生成规则;另一条数据就是UUID作为KEY,对应的值就是登陆者对象信息,也即上面定义的CurrentLoginUser。
成功后的redis中的场景如下图所示
设置登陆操作的成功,失败,异常,以及退出成功的处理器,spring security 框架本身定义了一系列的标准Handler.
@Configuration
public class SecurityHandlerConfig {
@Autowired
private IHnTokenService tokenService;
/**
* 登陆成功,返回Token
*
* @return
*/
@Bean
public AuthenticationSuccessHandler loginSuccessHandler() {
return new AuthenticationSuccessHandler() {
@Override
public void onAuthenticationSuccess(
final HttpServletRequest request,
final HttpServletResponse response,
final Authentication authentication) throws IOException, ServletException {
final CurrentLoginUser loginUser = (CurrentLoginUser) authentication.getPrincipal();
final SucceedResponse succeedResponse = new SucceedResponse();
loginUser.setPassword("");
loginUser.setLoginType("normalLogin");
final Token token = SecurityHandlerConfig.this.tokenService.saveToken(loginUser);
succeedResponse.addAttribute("token", token.getToken());
HnResponseUtils.responseJson(response, HttpStatus.OK.value(), succeedResponse);
}
};
}
/**
* 登陆失败
*
* @return
*/
@Bean
public AuthenticationFailureHandler loginFailureHandler() {
return new AuthenticationFailureHandler() {
@Override
public void onAuthenticationFailure(
final HttpServletRequest request,
final HttpServletResponse response,
final AuthenticationException exception) throws IOException, ServletException {
String code = null;
if (exception instanceof BadCredentialsException) {
code = "passwordError";// 密码错误
} else {
code = exception.getMessage();
}
HnResponseUtils.responseJson(response, HttpStatus.OK.value(), new ErrorResponse(code));
}
};
}
/**
* 未登录,返回200,主要是为了前端好统一处理
*
* @return
*/
@Bean
public AuthenticationEntryPoint authenticationEntryPoint() {
return new AuthenticationEntryPoint() {
@Override
public void commence(
final HttpServletRequest request,
final HttpServletResponse response,
final AuthenticationException authException) throws IOException, ServletException {
final String code = HnStringUtils.equals(authException.getMessage(), "kickOut") ? "kickOut" : "unLogin";
HnResponseUtils.responseJson(response, HttpStatus.OK.value(), new ErrorResponse(code));
}
};
}
/**
* 退出处理
*
* @return
*/
@Bean
public LogoutSuccessHandler logoutSussHandler() {
return new LogoutSuccessHandler() {
@Override
public void onLogoutSuccess(
final HttpServletRequest request,
final HttpServletResponse response,
final Authentication authentication) throws IOException, ServletException {
final String token = TokenFilter.getToken(request);
SecurityHandlerConfig.this.tokenService.deleteToken(token);
HnResponseUtils.responseJson(response, HttpStatus.OK.value(), new SucceedResponse("logout"));
}
};
}
}
以上所有的处理器返回给前端的内容都是code =200,前端判断成功与否的标准以自定义对象AjaxResponse 的"succeed"为 true还是false。这样处理,前端在解析后端返回的数据时好统一处理。
此类类似于以前的登陆拦截器,也即把每个操作都做登陆验证
@Component
public class TokenFilter extends OncePerRequestFilter {
@Autowired
private IHnTokenService tokenService;
/**
* token过期秒数
*/
@Value("${token.expire.seconds}")
private Integer expireSeconds;
public static final String TOKEN_KEY = "token";
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;
@Override
protected void doFilterInternal(
final HttpServletRequest request,
final HttpServletResponse response,
final FilterChain filterChain) throws IOException, ServletException {
final String token = TokenFilter.getToken(request);
// System.out.println("==============进入token过滤器" + token);
if (HnStringUtils.isNotBlank(token)) {
CurrentLoginUser loginUser = this.tokenService.getCurrentUser(token);
if (loginUser != null) {
try {
loginUser = this.checkLoginUser(loginUser, token);
} catch (final AuthenticationException e) {
SecurityContextHolder.clearContext();
this.authenticationEntryPoint.commence(request, response, e);
return;
}
final UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
/**
* 校验登录的账号,如过期时间、是否被踢出
* 过期时间与当前时间对比
* 如果已经过期则删除token
*
* @param loginUser
* @return
* @throws Exception
*/
private CurrentLoginUser checkLoginUser(final CurrentLoginUser loginUser, final String token) {
final long expireTime = loginUser.getExpireTime();
final long currentTime = System.currentTimeMillis();
final long time = expireTime - currentTime;
final String loginType = loginUser.getLoginType();
// 不是正常登陆则抛异常 AuthenticationException,此异常在SecurityHandlerConfig 异常处理Handler 的 authenticationEntryPoint中获取到
if (!HnStringUtils.equals(loginType, "normalLogin")) {
this.tokenService.deleteToken(token);
throw new HnAuthenticationException(loginType);
}
if (time <= 0) {
// System.out.println("****登陆失效");
// 已经过期则删除token和redis对应的用户
this.tokenService.deleteToken(token);
throw new HnException("unLogin");
} else if (this.expireSeconds * 1000 / 2 >= time) {
this.tokenService.refresh(loginUser);
}
return loginUser;
}
/**
* 根据参数或者header获取token
*
* @param request
* @return
*/
public static String getToken(final HttpServletRequest request) {
String token = request.getParameter(TokenFilter.TOKEN_KEY);
if (HnStringUtils.isBlank(token)) {
token = request.getHeader(TokenFilter.TOKEN_KEY);
}
return token;
}
}
这个类是关键,主要是加载配置文件的参数,密码的加密方式,以及如何将配置注入到spring security 安全框架中。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserDetailsService userDetailsService;
@Autowired
private AuthenticationSuccessHandler loginSuccessHandler;
@Autowired
private AuthenticationFailureHandler loginFailureHandler;
@Autowired
private AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
private LogoutSuccessHandler logoutSuccessHandler;
@Autowired
private TokenFilter tokenFilter;
/**
* 登陆页面
*/
@Value("${login.loginHTML}")
private String loginHtml;
/**
* 登陆后台处理页面
*/
@Value("${login.loginProcessingUrl}")
private String loginProcessingUrl;
/**
* 不拦截的页面
*/
@Value("${login.permitAllUrl}")
private String permitAllUrl;
/**
* 密码字段名称,默认password
*/
@Value("${login.passwordParameter}")
private String passwordParameter;
public String[] getPermitAllUrl() {
final String str = this.permitAllUrl.replaceAll(" ", "");
return str.split(",");
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(final HttpSecurity http) throws Exception {
http.csrf().disable();
if (HnStringUtils.isBlank(this.passwordParameter)) {
this.passwordParameter = "password";
}
// 基于token,所以不需要session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); http.authorizeRequests().antMatchers(this.getPermitAllUrl()).permitAll().anyRequest().authenticated(); http.formLogin().loginPage(this.loginHtml).loginProcessingUrl(this.loginProcessingUrl) .passwordParameter(this.passwordParameter).successHandler(this.loginSuccessHandler)
.failureHandler(this.loginFailureHandler).and().exceptionHandling()
.authenticationEntryPoint(this.authenticationEntryPoint).and().logout().logoutUrl("/logout")
.logoutSuccessHandler(this.logoutSuccessHandler);
// 解决不允许显示在iframe的问题
// http.headers().frameOptions().disable();
// http.headers().cacheControl();
http.addFilterBefore(this.tokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
protected void configure(final AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(this.userDetailsService).passwordEncoder(this.passwordEncoder());
}
}
以上就是使用Spring Security 、Oauth2、JWT实现登陆操作的主要代码,这些代码只要写在自己自定义的框架中,以后的项目只要是用帐号、密码登录的都可以统一使用,不用每次都自己写了,不同的项目只要编写符合自己登录逻辑的业务层即可,我这里只做个最简单的登陆判断的案例。
在service层编写一个实现接口UserDetailsService的类
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private ISysUserService sysUserService;
@Override
public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException {
System.out.println("==========" + new BCryptPasswordEncoder().encode("123456"));
SysUser user = new SysUser();
user.setUsername(username);
user = this.sysUserService.getOne(user);
if (user == null) {
// "用户名不存在"
final String code = "usernameNonExist";
throw new HnException(code);
}
final CurrentLoginUser loginUser = this.checkUser(user);
final Map<String, Object> map = new HashMap<String, Object>();
map.put("sex", user.getSex());
loginUser.setParamsMap(map);
return loginUser;
}
/**
* 验证当前用户及获取角色、组织等信息
* 根据项目实际情况获取
*
* @author huangmin
* @date 2021年6月7日
* @param user
* @return
*/
private CurrentLoginUser checkUser(final SysUser user) {
// 后台的路由资源权限
// 获取用户的角色
// 获取用户的组织
final CurrentLoginUser loginUser = new CurrentLoginUser();
loginUser.setId(user.getId());
loginUser.setUsername(user.getUsername());
loginUser.setPassword(user.getPassword());// 要将密码写入
loginUser.setLoginTime(System.currentTimeMillis());
return loginUser;
}
}
上面的代码就是一个最简单的登陆操作,需要注意的是,只要在checkUser(final SysUser user) 的这个方法中将密码存放到要返回的用户对象里,这样spring security框架会自动进行密码对比。
如果在实际的项目中需要做其它的逻辑处理,都可以在这里编写,例如权限,角色的获取等。
测试的效果如下
账号,密码错误
登陆成功
被踢出情况
注意:此处要将配置文件中的isMulti设为false;
登陆2次后用第一次的token进行操作,就会提示上图中的code:kickOut
大功告成!!!