登录业务
登录业务实现
(一)业务逻辑
用户输入完成登录信息
- 点击确定:服务器校验用户输入的登录信息
- 检验通过,页面跳转至主页面,并提示用户,登录成功。
- 校验失败,提示用户,登录失败,页面不跳转。
- 点击取消:清除用户输入的登录信息。
(二)业务流程
- 2.1 当页面加载时,自动请求并加载验证码。
- 2.2 当用户输入时,完成信息的非空校验。
- 2.3 用户输入完成时,完成输入信息的二次校验
- 校验通过,发起POST请求,将登录信息发送到服务器。服务器接收客户端发送的登录信息,查询数据库相关信息,完成登录校验。
- 登录检验成功,返回token。
- 登录校验失败,则抛出相关异常,由全局异常处理类对象响应客户端登录失败。
- 校验失败,提示用户失败原因。
- 校验通过,发起POST请求,将登录信息发送到服务器。服务器接收客户端发送的登录信息,查询数据库相关信息,完成登录校验。
(三)业务实现
3.1 前端页面
- html准备
<template>
<div class="myLogin">
<el-card shadow="always" class="loginCard">
<div>
<el-row type="flex" justify="center">
<el-col :span="6" align="end">
<img src="../assets/logo.png" width="38" height="38"/>
</el-col>
<el-col :span="18" align="left">
<span style="font-size: 24px; font-family: 楷体; font-weight: bolder; color: #2C03F9">旭峰科技信息管理系统</span>
</el-col>
</el-row>
<el-row type="flex" justify="center">
<el-col :span="22" style="margin-top: 25px">
<el-form ref="loginForm" :model="loginData" :rules="loginRules">
<!--用户名输入框-->
<el-form-item>
<el-input v-model="loginData.username" placeholder="请输入用户名..." auto-complete="false">
<i slot="prefix" class="fas fa-user myAwe"></i>
</el-input>
</el-form-item>
<!--密码输入框-->
<el-form-item>
<el-input type="password" v-model="loginData.password" placeholder="请输入密码..." auto-complete="false">
<i slot="prefix" class="fas fa-lock myAwe"></i>
</el-input>
</el-form-item>
<!--验证码及输入框-->
<el-form-item>
<el-row type="flex" justify="left">
<el-col :span="18">
<el-input v-model="loginData.captcha" placeholder="请输入验证码..." auto-complete="false">
<i slot="prefix" class="fas fa-shield-alt myAwe"></i>
</el-input>
</el-col>
<el-col :span="6">
<img :src="captchaUrl" />
</el-col>
</el-row>
</el-form-item>
<!--记住我-->
<el-form-item>
<el-checkbox v-model="loginData.rememberMe">Remember me</el-checkbox>
</el-form-item>
<!--登录按钮-->
<el-form-item align="center">
<el-button type="primary" style="width: 80%">确定</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
</div>
</el-card>
</div>
</template>
- js准备
export default {
name: "Login",
data(){
return{
loginData: {
username: "",
password: "",
captcha: "",
rememberMe: true,
captchaKey: ""
},
loginRules: {
},
captchaUrl: "",
}
}
}
- css准备
.myLogin {
display: flex;/*弹性布局*/
justify-content: center;/*容器内所有元素中的各项周围留有空白*/
align-items: center;/*容器内所有元素的对齐方式*/
background-image: url("../assets/login.jpg");
/*background-size: 100% 100%;*//*按容器比例撑满,图片变形*/
height: 100%;
background-size: cover;/*把背景图片放大到适合元素容器的尺寸,图片比例不变,但是要注意,超出容器的部分可能会裁掉。*/
}
.loginCard {
width: 450px;
height: 400px;
}
.myAwe{
margin-left: 5px
}
3.2实现验证码请求、响应及剩余时间显示
3.2.1 为验证码图片绑定点击事件用于刷新验证码。
<el-col :span="6">
<img :src="captchaUrl" @click="reflushCaptcha"/>
</el-col>
3.2.2定义点击事件处理函数
methods: {
reflushCaptcha()
{
//由于http会对频繁某一url发起相同请求时,会被认为是同一个请求,从而导致其它请求不会被发送,因此需要每次请求指定不同的参数。
this.loginData.captchaKey=Math.round(Math.random()*10000)+1;
this.captchaUrl="http://server.xfsy.com/captcha?key="+this.loginData.captchaKey;
}
},
3.2.3当vue对象创建完成后立即请求验证码
created() {
this.reflushCaptcha();
}
3.2.4 服务器生成验证码并保存验证码到redis中,并设置失效时间
-
Easycaptcha的使用
- 引入Maven依赖
<dependency> <groupId>com.github.whvcse</groupId> <artifactId>easy-captcha</artifactId> <version>1.6.2</version> </dependency>
-
Easycaptcha产生的验证码类型
-
public class Test { public static void main(String[] args) { // png类型 SpecCaptcha captcha = new SpecCaptcha(130, 48); captcha.text(); // 获取验证码的字符 captcha.textChar(); // 获取验证码的字符数组 // gif类型 GifCaptcha captcha = new GifCaptcha(130, 48); // 中文类型 ChineseCaptcha captcha = new ChineseCaptcha(130, 48); // 中文gif类型 ChineseGifCaptcha captcha = new ChineseGifCaptcha(130, 48); // 算术类型 ArithmeticCaptcha captcha = new ArithmeticCaptcha(130, 48); captcha.setLen(3); // 几位数运算,默认是两位 captcha.getArithmeticString(); // 获取运算的公式:3+2=? captcha.text(); // 获取运算的结果:5 captcha.out(outputStream); // 输出验证码 } }
-
-
Easycaptcha验证码设置
-
验证码字符类型
类型 描述 TYPE_DEFAULT 数字和字母混合 TYPE_ONLY_NUMBER 纯数字 TYPE_ONLY_CHAR 纯字母 TYPE_ONLY_UPPER 纯大写字母 TYPE_ONLY_LOWER 纯小写字母 TYPE_NUM_AND_UPPER 数字和大写字母 使用方法:
SpecCaptcha captcha = new SpecCaptcha(130, 48, 5); captcha.setCharType(Captcha.TYPE_ONLY_NUMBER);
只有
SpecCaptcha
和GifCaptcha
设置才有效果。 -
字体设置
内置字体:
字体 效果 Captcha.FONT_1 Captcha.FONT_2 Captcha.FONT_3 Captcha.FONT_4 Captcha.FONT_5 Captcha.FONT_6 Captcha.FONT_7 Captcha.FONT_8 Captcha.FONT_9 Captcha.FONT_10 使用方法:
SpecCaptcha captcha = new SpecCaptcha(130, 48, 5); // 设置内置字体 captcha.setFont(Captcha.FONT_1); // 设置系统字体 captcha.setFont(new Font("楷体", Font.PLAIN, 28));
-
设置宽高和位数
@Controller public class CaptchaController { @RequestMapping("/captcha") public void captcha(HttpServletRequest request, HttpServletResponse response) throws Exception { // 设置位数 CaptchaUtil.out(5, request, response); // 设置宽、高、位数 CaptchaUtil.out(130, 48, 5, request, response); // 使用gif验证码 GifCaptcha gifCaptcha = new GifCaptcha(130,48,4); CaptchaUtil.out(gifCaptcha, request, response); } }
-
-
redis的使用
-
所需依赖
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <!--<version>3.3.0</version>--> </dependency>
-
创建连接池
@PropertySource("classpath:/redis.properties") @Configuration public class RedisConfig { private final Logger logger = LoggerFactory.getLogger(RedisConfig.class); @Value("${redis.host}") private String jedisHost; @Value("${redis.port}") private Integer jedisPort; @Bean public JedisPool jedisPool() { logger.debug("Redis's host is {} and port is {}", jedisHost, jedisPort); JedisPoolConfig poolConfig = new JedisPoolConfig(); /*设置redis连接池中最大连接数*/ poolConfig.setMaxTotal(60); /*设置redis连接池中最大空间连接数,CSDN上强调,最好把最大连接数与最大空闲连接数设置一样,可以减少创建连接销毁连接过程,提高效率*/ poolConfig.setMaxIdle(60); poolConfig.setMinIdle(20); poolConfig.setMaxWait(Duration.ofSeconds(5)); poolConfig.setTestOnBorrow(false); return new JedisPool(poolConfig, jedisHost, jedisPort); } }
-
-
业务实现
-
服务器请求处理并响应:SpringSecurity必须放行发送/captcha的请求。
@GetMapping("/captcha") public void captcha(HttpServletRequest request, HttpServletResponse response) { /*获取当前请求验证码的key,做为保存到redis中的key*/ String captchaKey = request.getParameter("key"); /*创建算术验证码对象,并设置验证码的宽高*/ ArithmeticCaptcha captcha = new ArithmeticCaptcha(102, 42); /*获取验证码文本*/ String captchaText = captcha.text(); try { /*将验证码图片通过验证码工具类响应给前端*/ CaptchaUtil.out(captcha, request, response); } catch (IOException e) { e.printStackTrace(); } /*通过redis连接池获取jedis对象*/ Jedis jedis = jedisPool.getResource(); /*将验证码文本保存到redis中,并设置过期时间*/ jedis.setex(captchaKey, 300L, captchaText); /*释放资源*/ jedis.close(); }
-
客户端显示验证码过期时间
reflushCaptcha() { /*由运行结果可知,定时器是异步执行的*/ /*清理所有定时器*/ for(let i = 1; i <= this.captchaExpired; i++) { window.clearInterval(i); this.content = ""; } /*设置到期起始时间*/ let expireTime = 296; /*新建定时器,每秒变更到期间时间*/ this.captchaExpired = window.setInterval(_ =>{ expireTime -= 1; this.content = `验证码还剩 ${expireTime} s`; if(expireTime == 0) { this.content = "验证码以过期,请点击刷新" window.clearInterval(this.captchaExpired); } },1000); /*设置验证码请求路径及请求标识!*/ this.loginData.captchaKey=Math.round(Math.random()*10000)+1; this.captchaUrl="http://server.xfsy.com/captcha?key="+this.loginData.captchaKey; },
-
3.3 登录实现
3.3.1 前端用户输入校验
-
方法:利用el-form表单的校验功能。
-
检验规则:
- 用户名:
- 1.用户名不能为空,校验时机:当用户名输入框失去焦点时进行校验。
- 2.用户名的长度必须少于32个字符,同时多于0个字符。校验时机:失去焦点。
- 密码:
- 1.密码不能为空,校验时机:当密码输入框失去焦点时进行校验。
- 2.密码的长度必须大于等于6个字符少于等于64个字符。校验时机:失去焦点。
- 验证码:
- 验证码不能为空,检验时机:失去焦点。
- 用户名:
-
各表单域结果注意事项。
- 防止用户使用空格代替输入字符。
- 解决方法:
- 绑定失去焦点处理事件,利用字符串**trim()**函数来去掉多余的空字符。
-
具体实现
<el-form ref="loginForm" :model="loginData" :rules="loginRules" status-icon > <!--用户名输入框--> <el-form-item prop="username"> <el-input v-model="loginData.username" placeholder="用户名|手机号|邮箱地址..." @blur="loginData.username=loginData.username.trim()" auto-complete="false" maxleigth="32" minleigth="1" show-word-limit> <i slot="prefix" class="fas fa-user myAwe"></i> </el-input> </el-form-item> <!--密码输入框--> <el-form-item prop="password"> <el-input type="password" v-model="loginData.password" placeholder="请输入密码..." @blur="loginData.password=loginData.password.trim()" auto-complete="false" maxleigth="64" minleigth="6" show-word-limit> <i slot="prefix" class="fas fa-lock myAwe"></i> </el-input> </el-form-item> <!--验证码及输入框--> <el-form-item prop="captcha"> <el-row type="flex" justify="left"> <el-col :span="17"> <el-input v-model="loginData.captcha" placeholder="请输入验证码..." auto-complete="false" @blur="loginData.captcha=loginData.captcha.trim()"> <i slot="prefix" class="fas fa-shield-alt myAwe"></i> </el-input> </el-col> <el-col :span="7" align="end"> <img :src="captchaUrl" @click="reflushCaptcha"/> </el-col> </el-row> </el-form-item> <el-row type="flex" justify="end"> <el-col align="end"> <span style="color: red">{{content}}</span> </el-col> </el-row> <!--记住我--> <el-form-item> <el-checkbox v-model="loginData.rememberMe">Remember me</el-checkbox> </el-form-item> <!--登录按钮--> <el-form-item align="center"> <el-button type="primary" style="width: 90%" @click="submitLoginInfoAndToAtherPage">确定</el-button> </el-form-item> </el-form>
3.3.2 前端用户输入的二次检验。
-
通过后发送登录信息至服务器。校验不通过则提示用户输入有识。
-
目的:防止用户在无任何输入的情况下点击确定按钮。
-
具体实现
/*提交用户登录信息*/ async submitLoginInfoAndToAtherPage() { // 该方法实现了二次校验。 this.$refs.loginForm.validate(async value => { console.log(value) if(!value) { console.log("hello") this.$message.error("您的登录信息无效,请重新填写!"); return; } const {data: res} = await this.$axios.post("/login", this.loginData); await this.$router.push("/home"); }) }
3.3.3 服务器接收登录请求,完成登录响应。
3.3.3.1 SpringSecurity必须放行发往/login的请求。
-
定义SpringSecurity的UserDedials接口实现类
-
主要作用:用于SpringSecurity封装数据库中的保存的用户信息。
package org.wjk.pojo; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.Accessors; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; import java.util.List; @Data @EqualsAndHashCode(callSuper = true) @Accessors(chain = true) @TableName("sys_user") @Slf4j public class SysUserPojo extends BasePojo implements UserDetails { private static final long serialVersionUID = -8070005069587384401L; @TableId(type = IdType.AUTO) private Integer id; private String username; private String password; private String userFace; private Integer roleId; private Integer empId; private Boolean isEnabled; private Boolean isLocked; private String createdUser; private String modifiedUser; @TableField(exist = false) private List<String> permissions; @Override public Collection<? extends GrantedAuthority> getAuthorities() { return AuthorityUtils.createAuthorityList(permissions.toArray(new String[]{})); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return this.isLocked; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return this.isEnabled; } }
-
-
定义自定义SpringSecurity接口UserDetailService的实现类,重写
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
方法-
主要作用:
SpringSecurity
通过该方法从数据库中获取用户信息。 -
具体实现:
package org.wjk.config.security; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; import org.wjk.annotation.CacheReader; import org.wjk.dao.SysUserDao; import org.wjk.pojo.SysUserPojo; import java.util.List; @Component public class MyUserDetailService implements UserDetailsService { @Autowired private SysUserDao sysUserDao; @Override @CacheReader("LOGIN_USER::") public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUserPojo loginUser = sysUserDao.getUserByUsername(username); if(null == loginUser) throw new UsernameNotFoundException("该用户尚未注册,请先注册"); List<String> permission = sysUserDao.getUserPermissionById(loginUser.getId()); if(!permission.isEmpty()) loginUser.setPermissions(permission); return loginUser; } }
-
-
新建SpringSecurity配置类,该类必须继承WebSecurityConfigerAdpter
@Configuration public class MySecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyUserDetailService myUserDetailService; @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(myUserDetailService) .passwordEncoder(passwordEncoder()); } @Override public void configure(WebSecurity web) throws Exception { // 放行发往/login,/captcha的请求 web.ignoring().antMatchers("/login", "/captcha"); } }
3.3.3.2通过hibernate-validator完成请求携带参数的校验。
-
hibernate-validator
-
概念:
Java API
规范(JSR303
)定义了Bean
校验的标准validation-api
,但没有提供实现。hibernate validation
是对这个规范的实现,并增加了校验注解描述的如@Email
、@Length
等。Spring Validation
是对hibernate validation
的二次封装,用于支持spring mvc
参数自动校验。
-
使用:
-
引入依赖
- 如果
spring-boot
版本小于2.3.x
,spring-boot-starter-web
会自动传入hibernate-validator
依赖。如果spring-boot
版本大于2.3.x
,则需要手动引入依赖:
<!-- bean校验库依赖 --> <dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-validator</artifactId> <version>6.0.1.Final</version> </dependency>
- 如果
-
使用提供的注解描述的描述需要校验的Bean的属性,如:
@Data @Accessors(chain = true) public class LoginUser implements Serializable { private static final long serialVersionUID = -5296569653000222581L; @NotEmpty private String username; @NotEmpty private String password; @NotEmpty private String captcha; private Boolean rememberMe; @NotEmpty private String captchaKey; }
-
校验规则注解
-
JSR303/JSR-349: JSR303是一项标准,只提供规范不提供实现,规定一些校验规范即校验注解描述的,如@Null,@NotNull,@Pattern,位于javax.validation.constraints包下。JSR-349是其的升级版本,添加了一些新特性。
@Null 被注解描述的的元素必须为null
@NotNull 被注解描述的的元素必须不为null@AssertTrue 被注解描述的的元素必须为true
@AssertFalse 被注解描述的的元素必须为false
@Min(value) 被注解描述的的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value) 被注解描述的的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value) 被注解描述的的元素必须是一个数字,其值必须大于等于指定的最小值
@DecimalMax(value) 被注解描述的的元素必须是一个数字,其值必须小于等于指定的最大值
@Size(max, min) 被注解描述的的元素的大小必须在指定的范围内
@Digits (integer, fraction) 被注解描述的的元素必须是一个数字,其值必须在可接受的范围内
@Past 被注解描述的的元素必须是一个过去的日期
@Future 被注解描述的的元素必须是一个将来的日期
@Pattern(value) 被注解描述的的元素必须符合指定的正则表达式 -
hibernate validation:hibernate validation是对这个规范的实现,并增加了一些其他校验注解描述的,如@Email,@Length,@Range等等
@Email 被注解描述的的元素必须是电子邮箱地址
@Length 被注解描述的的字符串的大小必须在指定的范围内
@NotEmpty 被注解描述的的字符串的必须非空,且不能只是空格
@Range 被注解描述的的元素必须在合适的范围内
-
-
-
使用@validated|@valid描述controller方法中需要校验的参数。
-
对于使用POJO参数,直接使用上述两注解之一来描述参数即可,代码如:
@PostMapping("/login") public String login(@RequestBody @Validated LoginUser loginUser) { System.out.println(loginUser); userDetailService.loadUserByUsername(loginUser.getUsername()); return "登陆成功!"; }
-
对于基本类型及其包装类型及String类型的参数,则需要使用
@Validated
或@Valid
和校验规则注解直接描述参数即可,代码如:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PGW8QhIq-1638846847697)(./filepng/image-20211126184520650.png)]
-
-
快速失败
-
使用hibernate-validator完成校验时,默认情况下会校验所有校验元素,才会返回结果,效率不高。
-
快速失败则可以在第一次不符合校验规则的情况下,直接返回结果。具体实现如下:
-
新建配置类
package org.wjk.config.validate; import org.hibernate.validator.HibernateValidator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import javax.validation.Validation; import javax.validation.Validator; import javax.validation.ValidatorFactory; @Configuration public class ValidateConfig { @Bean public Validator validator() { ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class) .configure() // 快速失败模式 .failFast(true) .buildValidatorFactory(); return validatorFactory.getValidator(); } }
-
-
-
校验结果
-
-
当校验失败时,将会抛出
org.springframework.validation.BindException
异常。
-
3.3.3.3 定义统一响应封装
-
由服务器响应给客户端的信息大都不同,也不能确定业务流程是否成功,因此需要将对响应结果进行统一封装。具体代码如下:
import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; import java.io.Serializable; @Data @Accessors(chain = true) @NoArgsConstructor public class ResPaging implements Serializable { private static final long serialVersionUID = 7583645443893122438L; private Integer status; private String message; private Object data; private ResPaging(Integer status, String message, Object data) { this.status = status; this.message = message; this.data = data; } public static ResPaging failture (Integer status, String message) { return new ResPaging(status, message, null); } public static ResPaging failture(Integer status, String message, Object data) { return new ResPaging(status, message, data); } public static ResPaging success(String message) { return new ResPaging(2000, message, null); } public static ResPaging success(String message, Object data) { return new ResPaging(2000, message, data); } }
3.3.3.4 提供自定义异常
-
服务器运行期间根据用户的操作,可能出现很多未达到目的的情况或异常,为使异常信息比较友好,则需要提供自定义异常。
-
定义统一异常信息enum
package org.wjk.exception; public enum ExcInfo { ILLEGAL_ARGUMENTS(5000, "您的请求参数无效,请检查后重新输入!"), UNKNOW_EXCEPTION(5001, "未知错误,请稍后重试!"), ILLEGAL_PASSWORD(5002, "用户名或密码错误,请查证后重试!"), EXPIRED_CAPTCHA(5003, "验证码已过期,请点击刷新后重新填写!"), ILLEGAL_CAPTCHA(5004, "验证码错误,请检查后重新填写!"), ILLEGAL_TOKEN(5005,"您的token无效,请重新登录!"), EXPIRED_TOKEN(5006, "您的登录已过期,请重新登录!"), ILLEGAL_DB_CONNECT(5007, "可能出现数据库连接错误!"), DATA_CAST_ERROR(5008, "集合转换成节点失败,请检查!"), ACCOUNT_LOCKED(5011, "您的账户已锁定,请联系管理员!"), ACCOUNT_NOT_ENABLED(5012, "您的账号未启用,请联系管理员!"), PERMISSION_DENIED(5014, "您无权访问该资源或执行该操作,请联系管理员!"), DATA_NAME_ALREADY_EXISTS(5015, "同级下已存在同名的数据信息,不能重复添加,请检查后重试!"), ; private Integer statusCode; private String message; ExcInfo() { } ExcInfo(Integer statusCode, String message) { this.statusCode = statusCode; this.message = message; } public Integer getStatusCode() { return statusCode; } public String getMessage() { return message; } public void setStatusCode(Integer statusCode) { this.statusCode = statusCode; } public void setMessage(String message) { this.message = message; } }
-
定义自定义异常
-
由于需要抛出异常的时,都是在程序运行期间,因此自定义异常需要继承Runtime异常,具体代码如下:
public class ServiceException extends RuntimeException { private static final long serialVersionUID = 152467559091440056L; private Integer statusCode; public ServiceException(ExcInfu e) { super(e.getMessage()); this.statusCode = e.getStatusCode(); } }
-
3.3.3.5 定义统一异常处理
-
对于通过controller方法抛出的异常需要统一处理,因此需要统一异常处理类。
@RestControllerAdvice public class MyGlobalExceptionHandler { /*该异常则是hibernate-validator参数校验失败时抛出的异常*/ @ExceptionHandler(BindException.class) public ResPaging MyBindExceptionHandler(BindException e) { e.printStackTrace(); return ResPaging.failture(ExcInfo.ILLEGAL_ARGUMENTS); } @ExceptionHandler(ServiceException.class) public ResPaging MyServiceExceptionHandler(ServiceException e) { e.printStackTrace(); return ResPaging.failture(e.getStatusCode(), e.getMessage()); } @ExceptionHandler(RuntimeException.class) public ResPaging MyRuntimeException(RuntimeException exception) { exception.printStackTrace(); /*处理登录失败*/ if(exception instanceof UsernameNotFoundException) return ResPaging.failture(ExcInfo.ILLEGAL_PASSWORD); else if(exception instanceof LockedException) return ResPaging.failture(ExcInfo.ACCOUNT_LOCKED); else if(exception instanceof DisabledException) return ResPaging.failture(ExcInfo.ACCOUNT_NOT_ENABLED); /*处理权限不足异常*/ else if(exception instanceof AccessDeniedException) return ResPaging.failture(ExcInfo.PERMISSION_DENIED); else return ResPaging.failture(ExcInfo.UNKNOW_EXCEPTION); } }
3.3.3.6 服务端登录逻辑实现
-
业务逻辑逻辑
- 第一步:校验登录信息是否有效。无效时,抛出异常
- 第二步:登录信息有效时,获取登录信息中保存的验证码请求的key,从redis中获取对应的验证码。
- 如果没有对应的验证码则抛出验证码过期的ServiceException异常。
- 如果验证码存在则继续。
- 第三步:校验验证码是否正确
- 如果验证码不正确,则抛出验证码不确定的ServiceException异常。
- 如果验证码正确则继续
- 第四步:通过用户名查找数据库是否存在用户信息。
- 如果用户不存在,则抛出用户名错误异常。
- 如果用户存在则继续。
- 第五步:通过PasswordEncoder比对用户输入的密码与数据库中保存的密码是否一致。
- 如果不一致,则抛出密码不正确异常。
- 如果一致则继续。
- 第六步:将查询出的用户信息构建JWT token。
- 第七步:把用户信息和token响应给客户端。
-
具体实现:
-
校验登录信息是否有效:通过
hibernate-validator
的@Validated
描述请求携带的参数。public ResPaging login(@RequestBody @Validated LoginUser loginUser)
-
校验验证码
@PostMapping("/login") public ResPaging login(@RequestBody @Validated LoginUser loginUser) { /*校验验证码*/ Jedis jedis = jedisPool.getResource(); String captcha = jedis.get(loginUser.getCaptchaKey()); if(null == captcha) throw new ServiceException(ExcInfo.EXPIRED_CAPTCHA); if(!captcha.equals(loginUser.getCaptcha())) throw new ServiceException(ExcInfo.ILLEAGL_CAPTCHA); /*检验用户登陆信息*/ return ResPaging.success("登陆成功!", tokenPlayload); }
-
校验用户登陆信息
-
查询数据中是否存在用户输入的用户名的用户信息:自定义MyUserDetailsServiceImpl实现SpringSecurity的UserDetailsService。
-
如果有则查询数据库用户所拥有权限,完成UserDetails对象的配置。
-
如果没有则抛出SpringSecurity的UsernameNotFoundException异常。
-
具体实现
package org.wjk.config.security; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; import org.wjk.annotation.CacheReader; import org.wjk.dao.SysUserDao; import org.wjk.pojo.SysUserPojo; import java.util.List; @Component public class MyUserDetailService implements UserDetailsService { @Autowired private SysUserDao sysUserDao; @Override @CacheReader("LOGIN_USER::") public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUserPojo loginUser = sysUserDao.getUserByUsername(username); if(null == loginUser) throw new UsernameNotFoundException("无效的用户名"); if(!loginUser.getIsEnabled()) throw new DisabledException("您的账号未启用,请联系管理员!"); if(loginUser.getIsLocked()) throw new LockedException("您的账户已锁定,请联系管理员!"); List<String> permission = sysUserDao.getUserPermissionById(loginUser.getId()); if(!permission.isEmpty()) loginUser.setPermissions(permission); return loginUser; } }
-
-
校验用户登录输入的密码是否正确。
-
如果校验成功将用户返回给Controller层。
-
如果校验失败则抛出异常。由全局异常处理响应给前端。
-
具体实现:定义LoginServiceImpl。
@Service public class LoginServiceImpl implements LoginService { @Autowired private UserDetailsService userDetailsService; @Autowired private PasswordEncoder passwordEncoder; @Override public UserDetails login(LoginUser loginUser) { UserDetails loginUserInfo = userDetailsService.loadUserByUsername(loginUser.getUsername()); // 通过SpringSecurity的PasswordEncoder实现类对象完成密码校验。 if(!passwordEncoder.matches(loginUser.getPassword(), loginUserInfo.getPassword())) // 密码校验失败,由全局异常处理处理结果 throw new ServiceException(ExcInfo.ILLEGAL_PASSWORD); // 校验成功后,返回当前用户信息给Controller return loginUserInfo; } }
-
-
-
通过后返回token
-
JWT概述
-
概念:
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
-
构成:
第一部分我们称它为头部(header),
第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),
第三部分是签证(signature).
-
header
jwt的头部承载两部分信息:
- 声明类型,这里是jwt
- 声明加密的算法 通常直接使用 HMAC SHA256
-
playload
载荷就是存放有效信息的地方。这些有效信息包含三个部分
- 标准中注册的声明
- 公共的声明
- 私有的声明
标准中注册的声明 (建议但不强制使用) :
- iss: jwt签发者
- sub: jwt所面向的用户
- aud: 接收jwt的一方
- exp: jwt的过期时间,这个过期时间必须要大于签发时间
- nbf: 定义在什么时间之前,该jwt都是不可用的.
- iat: jwt的签发时间
- jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
公共的声明 :
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.私有的声明 :
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。 -
signature
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
- header (base64后的)
- payload (base64后的)
- secret
这个部分需要base64加密后的header和base64加密后的payload使用
.
连接组成的字符串,然后通过header中声明的加密方式进行加盐secret
组合加密,然后就构成了jwt的第三部分。 -
注意事项
将这三部分用
.
连接成一个完整的字符串,构成了最终的jwt注意:secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了。
-
-
Springboot整合JWT:
-
JWT的引入:
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
-
需要灵活配置的属性
#前端发送请求时,携带token的key jwt.header=Authorization #获取荷载中token的head jwt.head=loen #过期时间:单位毫秒 jwt.expired=60*60*24*1000 #signature中的secret jwt.secret=cims-bs-server
-
定义JWT工具类。
- 构建token。通过调用JwtBuilder类的compact()方法。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eDo3g7RQ-1638846847697)(./filepng/image-20211130125507369.png)]
-
具体说明:
-
token是通过荷载Claims或playholder两者之一构造的,两者不能同时提供,否则compact()将报异常。
-
token构建是荷载一般必须两个属性:sub和exp
- sub:即token中保存的必要信息。
- exp:即token的过期时间,当token过了过期时间时,解析token时会抛出
ExpiredJwtException
异常。
-
具体实现:
-
通过Jwts.builder()方法直接构建。
/*1.通过信息构建token*/ public String buildLoginUserToken(UserDetails userDetails) { return Jwts.builder() .setSubject(userDetails.getUsername()) .setExpiration(new Date(System.currentTimeMillis() + expired)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); }
-
也可以先构建Claims对象,通过Claims对象构建token
/*工具方法:构建荷载*/ public Claims buildClaims(UserDetails userDetails) { Claims claims = new DefaultClaims(); claims.setSubject(userDetails.getUsername()); claims.setExpiration(new Date(System.currentTimeMillis() + expired)); return claims; } /*1.通过信息构建token*/ public String buildLoginUserToken(UserDetails userDetails) { return Jwts.builder() .setClaims(buildClaims(userDetails)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); }
-
-
-
解析token。
-
解析过程:
- 第一步:将token转换成Claims对象
- 第二步:通过Claims对象的getSubject()方法获取token中保存的信息。
-
将token转换成Claims的方法是:Jwts类中的如下方法完成。
public static JwtParser parser() { return new DefaultJwtParser(); }
-
具体实现如下:
public Claims getClaimsByToken(String token) throws ExpiredJwtException { if(null == token) return null; return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); }
-
将token转换成Claims过程中将抛出如下异常
UnsupportedJwtException
:当token不是通过Claims对象构建的token时。ExpiredJwtException
:当token已过期时。MalformedJwtException
:当token不是有效的Claims对象构建的token时。SignatureException
:当token的Signature验证失败时。IllegalArgumentException
:当token为null或token是空字符串或token中只有空字符时。
-
通过Claims的方法获取token中保存的信息。
-
具体实现如下:
/*2.通过token获取构建时的信息*/ public String getUserNameFromToken(String token) throws Exception { Claims claims = getClaimsByToken(token); return claims.getSubject(); }
-
-
-
响应客户端返回token
-
具体实现
@PostMapping("/login") public ResPaging login(@RequestBody @Validated LoginUser loginUser) { /*校验验证码*/ Jedis jedis = jedisPool.getResource(); String captcha = jedis.get(loginUser.getCaptchaKey()); if(null == captcha) throw new ServiceException(ExcInfo.EXPIRED_CAPTCHA); if(!captcha.equals(loginUser.getCaptcha())) throw new ServiceException(ExcInfo.ILLEGAL_CAPTCHA); /*检验用户登陆信息*/ SysUserPojo userDetails = loginService.login(loginUser); Map<String, Object> res = new HashMap<>(); /*构建token*/ res.put("tokenHead", tokenHead); res.put("tokenBody", jwtUtils.buildLoginUserToken(userDetails)); return ResPaging.success("登陆成功!", res); }
-
-
-
-
定义登录失败的处理逻辑
-
引发登录失败的原理主要有:
-
当使用SpringSecurity完成登录时
- 用户名对应的用户不存在,即抛出了
UsernameNotFoundException
时,此时SpringSecurity
将抛出BadCredentialsException
。 - 用户凭证过期,即当
UserDetails
的boolean isCredentialsNonExpired();
的返回值为false时,此时SpringSecurity
将抛出CredentialsExpiredException
。 - 用户未启用,即当
UserDetails
的boolean isEnabled();
的返回值为false时,此时SpringSecurity
将抛出DisabledException
。 - 用户被锁定,即当
UserDetails
的boolean isAccountNonLocked();
的返回值为false时,此时SpringSecurity
将抛出LockedException
。 - 用户以过期,即当
UserDetails
的boolean isAccountNonExpired();
的返回值为false时,此时SpringSecurity
将抛出AccountExpiredException
。 - 其它原因。
- 用户名对应的用户不存在,即抛出了
-
定义登录失败的处理器。
-
该处理器需要实现
AuthenticationFailureHandler
接口,并实现public void onAuthenticationFailure(HttpServletRequest request,HttpServletResponse response, AuthenticationException e) throws IOException, ServletException
方法。 -
具体实现
-
-
package org.wjk.config.security; import org.springframework.security.authentication.*; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Component; import org.wjk.exception.ExcInfo; import org.wjk.utils.JsonResUtils; import org.wjk.vo.ResPaging; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { ResPaging res = null; if(exception instanceof BadCredentialsException) res = ResPaging.failture(ExcInfo.ILLEGAL_PASSWORD); else if(exception instanceof CredentialsExpiredException) res = ResPaging.failture(ExcInfo.EXPIRED_CREDENTIALS); else if(exception instanceof AccountExpiredException) res = ResPaging.failture(ExcInfo.EXPIRED_ACCOUNT); else if(exception instanceof LockedException) res = ResPaging.failture(ExcInfo.ACCOUNT_LOCKED); else if(exception instanceof DisabledException) res = ResPaging.failture(ExcInfo.ACCOUNT_NOT_ENABLED); else res = ResPaging.failture(ExcInfo.LOGIN_FAILTURE); JsonResUtils.response(request, response, res); } }
-
将自定义登录失败处理逻辑配置到
SpringSecurity
- 将该处理逻辑注入到
MySecurityConfig
- 将该处理逻辑注入到
@Autowired private AuthenticationFailureHandler authenticationFailureHandler;
@Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() /*配置所有请求都需要登录认证*/ .anyRequest().authenticated() .and() /*登录失败的处理逻辑配置*/ .formLogin() .failureHandler(authenticationFailureHandler) .and() /*配置自定义退出逻辑*/ .logout().permitAll() .addLogoutHandler(logoutHandler) .logoutSuccessHandler(logoutSuccessHandler) .and() /*关闭跨站请求伪造*/ .csrf().disable() /*配置session管理器*/ .sessionManagement() /*由于使用jwt令牌来保存用户登录信息,因此不需要使用创建session来保存用户登录信息*/ .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() /*将token校验过滤器添加在认证过滤器前*/ .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class) ; }
-
当使用SpringMVC完成登录时,需要自行抛出以上异常,并在全局异常处理类里处理
-
异常抛出,一般在
UserDetailServiceImpl
中import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; import org.wjk.annotation.CacheReader; import org.wjk.dao.SysUserDao; import org.wjk.pojo.SysUserPojo; import java.util.List; @Component public class MyUserDetailService implements UserDetailsService { @Autowired private SysUserDao sysUserDao; @Override @CacheReader("LOGIN_USER::") public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUserPojo loginUser = sysUserDao.getUserByUsername(username); if(null == loginUser) throw new UsernameNotFoundException("无效的用户名"); if(!loginUser.getIsEnabled()) throw new DisabledException("您的账号未启用,请联系管理员!"); if(loginUser.getIsLocked()) throw new LockedException("您的账户已锁定,请联系管理员!"); List<String> permission = sysUserDao.getUserPermissionById(loginUser.getId()); if(!permission.isEmpty()) loginUser.setPermissions(permission); return loginUser; } }
-
在全局异常处理类中处理
@ExceptionHandler(RuntimeException.class) public ResPaging MyRuntimeException(RuntimeException exception) { exception.printStackTrace(); /*处理登录失败*/ if(exception instanceof UsernameNotFoundException) return ResPaging.failture(ExcInfo.ILLEGAL_PASSWORD); else if(exception instanceof LockedException) return ResPaging.failture(ExcInfo.ACCOUNT_LOCKED); else if(exception instanceof DisabledException) return ResPaging.failture(ExcInfo.ACCOUNT_NOT_ENABLED); /*处理权限不足异常*/ else if(exception instanceof AccessDeniedException) return ResPaging.failture(ExcInfo.PERMISSION_DENIED); else return ResPaging.failture(ExcInfo.UNKNOW_EXCEPTION); }
-
-
-
3.3.4 前端对token及当前用户信息的处理。
3.3.4.1 保存token及当前用户信息至sessionStorage中
-
具体实现
/*提交用户登录信息*/ submitLoginInfoAndToAtherPage() { this.$refs.loginForm.validate(async value => { if(!value) { this.$message.error("您的登录信息无效,请重新填写!"); return; } const {data: res} = await this.$axios.post("/login", this.loginData); if(res.status == 2000 && res.data) { //await this.$router.push("/home"); /*保存构建token并保存到sessionStorage里*/ let token = res.data.tokenHead+res.data.tokenBody; sessionStorage.setItem("token", token); sessionStorage.setItem("username", this.loginData.username); await this.$router.push("/home"); this.$message.success(res.message); } else { this.$message.error(res.message); } }) }
3.3.4.2 为每次ajax请求设置携带token
-
设置位置:
/src/axios/index.js
-
具体设置:定义axios的请求拦截器,代码实现如下:
import Vue from "vue" import axios from "axios" axios.defaults.baseURL="/api" axios.interceptors.request.use(config =>{ let token = sessionStorage.getItem("token"); if(token != undefined && token.trim().length > 0) config.headers.Authorization = token; return config; }) Vue.prototype.$axios = axios
3.3.4.3 实现除/login路径能够直接访问,其它路径都必须拥有token才能访问
-
设置位置:
/src/router/index.js
-
具体设置:定义前置路由导航守卫,代码实现如下:
const router = new VueRouter({ routes }) router.beforeEach((to, from, next) => { let token = sessionStorage.getItem("token"); if("/login" == to.path || (token && token.trim().length > 0)) next(); else next("/login"); })
-