配置类
SpringSecurity 要求配置类继承 WebSecurityConfigurerAdapter,并重写其中的 configure 方法。我们先进行基本的配置:
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 关闭 SpringSecurity 自带的 CSRF 保护,jwt 天生防 CSRF
http.csrf().disable();
// 禁止通过 session 去获取 SecurityContext
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.authorizeRequests()
// 这里一定要以 / 开头 !!!
// 需要认证的接口
.antMatchers("/login/refresh", "/login/out").authenticated()
// 放行的接口
.antMatchers("/websocket", "/login/**").permitAll()
// 其它的接口需要认证
.anyRequest().authenticated();
}
}
http.csrf().disable()
,SpringSecurity 自带一个 CSRF 防御机制,这个机制要求我们在请求头中携带 X-CSRF-TOKEN,具体实现可以看:https://blog.csdn.net/fxtxz2/article/details/129496488。而 JWT 没有使用 cookie 存储,直接断了 CSRF 攻击的可能,所以我们也不需要做额外工作来使用 SpringSecurity 的 CSRF 防御机制了,直接把它关了就行。SessionCreationPolicy.STATELESS
,SpringSecurity 不会创建 Session 也不会尝试去从 Session 获取 SecurityContext。我们的每次请求都会用 JWT 重新进行身份认证,不会依赖于之前的创建的 Session。- 设置认证和放行的接口时,应注意:
- 接口一定要以
/
开头!!! - 越具体的接口越先配置,这样也会被越先检查
- 接口一定要以
认证流程
引入 SpringSecurity 后,SpringSecurity 会有一个默认的认证流程
- Authentication 接口的实现类表示当前访问系统的用户,里面封装了用户相关的信息
- AuthenticationManager 接口定理了认证 Authentication 的方法
- UserDetailsService 接口定义了一个根据用户名查询出完整的用户信息的方法
- UserDetails 接口提供了完整的用户信息
通过 UserDetailsService 查询出的用户信息要封装成 UserDetails 对象,再存入到 Authentication 对象中。
基本原理
HTTPS 会自动加密数据,防止被第三方窃听,所以前端可以直接传明文密码给后端,不需要做额外的安全工作。所以现在只需要保护好数据库中的密码,做到即使数据库泄露,黑客也无法成功登入系统。哈希类的函数可以很轻松的实现这个需求:
- 我们在数据库中存储 h(pwd)
- 校验时先从数据库取出 h(pwd),检查 h(pwd_input) == h(pwd),pwd_input 是前端传入的待校验的密码
- 校验通过时,给前端返回一个登录凭证:JWT(JSON Web Token)
在 SpringSecurity 中,h() 函数由 PasswordEncoder提供,取出 h(pwd) 的操作由 UserDetailsService 执行,整个校验过程由 AuthenticationManager 控制。校验成功后,用户的详细信息会被存入 SecurityContext,供其它业务方法访问。
AuthenticationManager
认证时需要调用 AuthenticationManager 的 authenticate 方法,所以我们需要将 AuthenticationManager 提前暴露出去。
@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
/* 其它的代码 ...... */
/**
* 在这里重写 authenticationManagerBean 并加上 @Bean
* 来暴露 AuthenticationManager 对象
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
PasswordEncoder
默认的 PasswordEncoder 要求数据库的密码格式为:{id}password,它会根据 id 去判断密码的加密方式,我们也可以通过 {noop}password 来实现存储明文密码。
在实际项目中,我们一般使用 SpringSecurity 提供的 BCryptPasswordEncoder。只需要将其注入到 Spring 容器,SpringSecurity 就会使用 BCryptPasswordEncoder 来进行密码校验。
@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
/* 其它的代码 ...... */
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
BCryptPasswordEncoder 的 encode 方法每次在调用时都会生成一个随机的盐,在将盐与明文密码一起哈希,得到密文。当调用 matches 方法时,哈希过的盐值来判断传入的明文是否正确。
UserDetailsService
UserDetailsService 接口只有一个需要实现的方法: loadUserByUsername
- 入参为 username,但不是字面意义上的用户名,而是用户的一个唯一标识,所以邮箱、手机号、微信号等等都可以作为 username
- 返回一个 UserDetails 对象,也需要我们自己实现
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return new LoginUser(userService.getUser(username));
}
}
我们首先定义一个自己的 User 类, 再去实现 UserDetails 接口
@Data
public class User {
private Long id;
private String wxId;
private String email;
private String phone;
private String pwd;
private String name;
private String headImg;
private String role;
// 性别,0为保密,1 为 男,2 为女
private String gender;
// 删除标志
private boolean enable;
// 权限
private List<String> authorities;
public User () {
this.gender = "0";
this.enable = true;
}
}
这里 LoginUser 实现了 UserDetails 接口,内部有一个 User 对象的引用。可以发现 LoginUser 其实也就是 User 套了层壳,里面我们用到的方法如 isEnabled 可以实现下,没用到的如 isAccountNonLocked 直接返回 true 就可以了。
@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {
private User user;
/**
* SimpleGrantedAuthority 没有无参构造器,无法被 jackson 反序列化
* 索性就先存 user 的 authorities
* 用到 LoginUser 的 authorities 时再进行转换
*/
private List<SimpleGrantedAuthority> authorities;
public LoginUser(User user) {
this.user = user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Optional.ofNullable(authorities)
.orElseGet(() -> authorities = Optional.ofNullable(user.getAuthorities())
.map(list -> list.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList()))
.orElse(Collections.emptyList()));
}
@Override
public String getPassword() {
return user.getPwd();
}
// 返回用户的唯一标识
@Override
public String getUsername() {
return user.getId().toString();
}
// 账号是否过期,e.g. 试用期
@Override
public boolean isAccountNonExpired() {
return true;
}
// 用户是否被锁定
@Override
public boolean isAccountNonLocked() {
return true;
}
// 凭证是否未过期
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 用户是否被启用
@Override
public boolean isEnabled() {
return user.isEnable();
}
}
userService.getUser() 方法的代码如下:
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserDao userDao;
@Override
public User getUser(String account) {
User user;
// 查询 user 信息
if (account.matches(emailRegex)) {
user = userDao.getUserByEmail(account);
} else if (account.matches(phoneRegex)) {
user = userDao.getUserByPhone(account);
} else {
try {
user = userDao.getUserById(Long.valueOf(account));
} catch (NumberFormatException e) {
throw new CustomException(ExceptionEnum.INVALID_ACCOUNT);
}
}
// 判断用户是否存在
Optional.ofNullable(user).orElseThrow(() -> new CustomException(ExceptionEnum.USER_NOT_EXIST));
// 查询 user 对应的权限信息
user.setAuthorities(userDao.getAuthorities(user.getId()));
return user;
}
}
<select id="getUserById" resultType="com.js.a27.domain.user.User">
select * from [user] where id = #{id}
</select>
<select id="getUserByEmail" resultType="com.js.a27.domain.user.User">
select * from [user] where email = #{email}
</select>
<select id="getUserByPhone" resultType="com.js.a27.domain.user.User">
select * from [user] where phone = #{phone}
</select>
<select id="getAuthorities" resultType="java.lang.String">
select role from [authorities] where user_id = #{userId}
</select>
JWT
推荐:https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
为什么要用 JWT ?
Session 存储在服务器,多个服务器间的 Session 是不共享的,访问新的服务器就需要重新认证,很麻烦。JWT 是这种跨域认证的一个解决方案:服务器 A 签发一个登录凭证(JWT),而服务器 B、C 都承认服务器 A 签发的登录凭证。前端每次请求都携带 A 签发的登录凭证,无需二次授权就可以访问服务器 B 和 C 了。
组成
JWT 由服务器签发,由三部分组成:
- Header(头部)
- 令牌(token)类型,JWT 令牌统一写 JWT
- 使用的签名算法
- Palyload(荷载),存放数据的 JSON 对象,JWT 提供了 7 个官方字段做参考
- iss (issuer):签发人
- exp (expiration time):过期时间
- sub (subject):主题,通常在这里存放用户数据
- aud (audience):受众
- nbf (Not Before):生效时间
- iat (Issued At):签发时间
- jti (JWT ID):编号
- Signature(签名),签名的计算需要一个密钥,这个密钥只有服务器拥有。使用 Header 中指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
最后返回 base64UrlEncode(Header) + "." + base64UrlEncode(Payload) + "." + Signature。
可以发现 JWT 的前两部分是没有任何加密的,任何人都可以读取或修改,所以不应存放敏感信息。为了防止前两部分的信息被篡改,JWT 的签发者会使用密钥计算出前两部分的一个签名。每次解析传入的 JWT 时,签发者会使用密钥去重新计算传入的 JWT 的签名,并比较计算出的签名是否与传入的 JWT 的签名一致,如果不一致则代表 JWT 被篡改。例:黑客注册了一个新用户,拿到了 userId 为 8 的 JWT,随后将 Palyload.subject.userId 修改为 3,尝试以 userId 为 3 的用户的身份去访问服务器。服务器计算出签名后,发现计算的签名与传入的不一致,拒绝其访问。
过期时间
JWT 过期后,让用户重新输密码登录显然是一种很差的体验方式。无感刷新 JWT 有多种方案,这里我们用最常见的 access_token 和 refresh_token 的方案:
- 用户登录成功后签发两个 JWT:access_token 和 refresh_token
- access_token 的有效时间短,用于正常访问服务器
- refresh_token 的有效时间很长,用于在 access_token 过期时,去向服务器换取新的 access_token 和 refresh_token
刷新流程如下:
前端需要封装下 Axios:
import axios from "axios";
let baseURL = "http://localhost/api"
const myAxios = axios.create({baseURL})
/* 添加请求拦截器 */
myAxios.interceptors.request.use(config => {
/* 每次请求都携带token */
let token = sessionStorage.getItem("access_token")
if (token) config.headers['Authorization'] = token
return config
}, error => Promise.reject(error))
/* 添加响应拦截器 */
myAxios.interceptors.response.use(async res => {
switch (res.data.code) {
// access_token 过期状态码
case 3000:
/* 刷新 access_token */
// 用原生 axios 进行,即使失败也不会走回拦截器,造成死循环
await axios({
baseURL,
url: "login/refresh",
method: "get",
headers: {
Authorization: sessionStorage.getItem("refresh_token")
}
}).then(res => {
let data = res.data.data
sessionStorage.setItem("access_token", data["access_token"])
sessionStorage.setItem("refresh_token", data["refresh_token"])
}).catch(() => {
// TODO 跳回登录界面
throw "token 过期"
})
// 配置上新的 access_token,重新发起请求
res.config.headers.Authorization = sessionStorage.getItem("access_token")
return axios(res.config)
}
return res
})
后端需要提供一个刷新接口:
@RestController @RequestMapping("login") @RequiredArgsConstructor public class LoginController { private final UserService userService;
/* 以 refresh_token 去获取新的 access_token 和 refresh_token */ @GetMapping("refresh") ResponseResult refresh() { return ResponseResult.success(userService.refresh()); }
}
@Service
public class UserServiceImpl implements UserService {
LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Long userId = loginUser.getUser().getId();
// 刷新 redis 缓存的过期时间
redisTemplate.expire(RedisConst.USER_LOGGED_KEY + userId, RedisConst.USER_KEY_DURATION, TimeUnit.MILLISECONDS);
// 返回 jwts
return JwtUtils.createJWTs(userId.toString());
}
工具类
public class JwtUtils {
// 秘钥明文
public static final String JWT_KEY = "xxxxxx";
public static Map<String, String> createJWTs(String subject) {
/* 创建 jwt /
Map<String, String> jwts = new HashMap<>();
// access_token 有效时间为 1 小时
jwts.put("access_token", buildJWT(subject, 60 * 60 * 1000L));
// refresh_token 有效时间为 15 天
jwts.put("refresh_token", buildJWT(subject, 15 * 24 * 60 * 60 * 1000L));
return jwts;
}
private static String buildJWT(String subject, Long ttlMillis) {
long nowMillis = System.currentTimeMillis();
return Jwts.builder()
// 设置一个 uuid
.setId(UUID.randomUUID().toString().replaceAll("-", ""))
// 签发者
.setIssuer("1270778")
// 签发时间
.setIssuedAt(new Date(nowMillis))
// 过期时间
.setExpiration(new Date(nowMillis + ttlMillis))
// 加密算法和密钥
.signWith(SignatureAlgorithm.HS256, JWT_KEY)
// 主题
.setSubject(subject)
.compact();
}
public static Claims parseJWT(String jwt) {
try {
return Jwts.parser()
.setSigningKey(JWT_KEY)
.parseClaimsJws(jwt)
.getBody();
} catch (ExpiredJwtException e) {
// 返回 null,让外面的方法抛异常
return null;
}
}
}
JWT 解析认证
前端每次都会携带 JWT,解析 JWT 并认证的逻辑需要我们自己实现。我们在 SpringSecurity 最靠前的过滤器 UsernamePasswordAuthenticationFilter 前加一个自定义的 JWT 解析认证过滤器:
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
/其它代码 ...... /
@Override
protected void configure(HttpSecurity http) throws Exception {
/ 其它代码 ...... /
// 第一个参数指定添加的过滤器,第二个指定位于哪一个过滤前前面
http.addFilterBefore(new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String token = request.getHeader("Authorization");
if (!StringUtils.hasText(token)) {
/
1. 这里直接放行并返回,没有已授权的 Authentication 的 SecurityContext 是通不过后面的过滤器的
2. 登录接口即使没有 Authorization 头也应可以使用,如果这里不放行并直接返回,会导致登录接口始终无法使用
/
filterChain.doFilter(request, response);
return;
}
// 解析 jwt 获取 userId
Claims claims = JwtUtils.parseJWT(token);
// 如果 jwt 过期,claims 会返回 null
if (claims == null) {
writeResponse(response, objectMapper.writeValueAsString(ResponseResult.error(ExceptionEnum.TOKEN_EXPIRED)));
return;
}
String userId = claims.getSubject();
String key = RedisConst.USER_LOGGED_KEY + userId;
// 从 redis 取出 user
User user = Optional.ofNullable((User) redisTemplate.opsForValue().get(key))
.orElseThrow(() -> new CustomException(ExceptionEnum.AUTHENTICATION_FAILURE));
// 把 user 封装成 loginUser
LoginUser loginUser = new LoginUser(user);
/
把 loginUser 封装为已授权的 Authentication
Authentication 的有三个参数的构造器中 authenticated 属性会被设置会 true
/
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
// 把已授权的 Authentication 存入 SecurityContextHolder
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
// 放行
filterChain.doFilter(request, response);
}
}, UsernamePasswordAuthenticationFilter.class);
}
}
自定义失败处理
ExceptionTranslationFilter 会捕获认证或授权过程中的异常。认证过程中的异常会被封装成 AuthenticationException 后交由 AuthenticationEntryPoint 处理。授权过程中的异常会被封装成 AccessDeniedException 后交由 AccessDeniedHandler 处理。我们只需自定义 AuthenticationEntryPoint 和 AccessDeniedHandler 再配置给 SpringSecuriy 即可实现自定义失败处理。
@Configuration
@Slf4j
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
/其它代码 /
// 配置认证失败处理器
http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
writeResponse(response, objectMapper.writeValueAsString(
ResponseResult.error(ExceptionEnum.AUTHENTICATION_FAILURE)
));
log.info("ip: " + request.getRemoteAddr() + " 访问 " + request.getRequestURL() + " 认证失败");
});
// 配置授权失败处理器
http.exceptionHandling().accessDeniedHandler((request, response, accessDeniedException) -> {
writeResponse(response, objectMapper.writeValueAsString(ResponseResult.error(
ExceptionEnum.AUTHORITY_FAILURE)
));
log.info("ip: " + request.getRemoteAddr() + " 访问 " + request.getRequestURL() + " 授权失败");
});
}
}
账号密码登录
有了上面的代码基础,我们就可以很轻松的实现一个邮箱/手机号 + 密码登录功能。
@RestController
@RequestMapping("login")
@RequiredArgsConstructor
public class LoginController {
/account + pwd 登录 */
@PostMapping("pwd")
ResponseResult logByPwd(@RequestBody Map<String, String> user) {
Map<String, String> jwts = userService.login(user);
return jwts.isEmpty() ? ResponseResult.error("登录失败") : ResponseResult.success(jwts);
}
}
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {private final AuthenticationManager authenticationManager;
private final RedisTemplate<String, Object> redisTemplate;
@Override
public Map<String, String> login(Map<String, String> user) {
// 用暴露出的 AuthenticationManager 的 authenticate 方法进行认证
Authentication authentication = authenticationManager.authenticate(
/*
authenticate 传入一个 authentication,并开始认证
认证成功返回封装有 user 数据的 authentication
认证失败,则抛出 AuthenticationException 异常
/
new UsernamePasswordAuthenticationToken(user.get("account"), user.get("pwd")));
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long userId = loginUser.getUser().getId();
// 将 user 存储到 redis 中,有效期为 20 天
redisTemplate.opsForValue()
.set(RedisConst.USER_LOGGED_KEY + userId, loginUser.getUser(),
RedisConst.USER_KEY_DURATION, TimeUnit.MILLISECONDS);
// 返回 jwt
return JwtUtils.createJWTs(userId.toString());
}
}
验证码登录
短信和邮箱需要导入以下依赖
<!-- 阿里短信服务 -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.4.0</version>
</dependency>
<!-- 邮箱 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
大致流程如下:
代码实现如下:
@RestController
@RequestMapping("login")
@RequiredArgsConstructor
public class LoginController {
private final UserService userService;
/获取图片验证码 /
@PostMapping("code/graph")
ResponseResult getGraphCode(@RequestBody Map<String, String> body) {
return ResponseResult.success(userService.getGraphCode(body.get("account")));
}
/ 获取邮箱/手机验证码 /
@PostMapping("code/verification/get")
ResponseResult getVerificationCode(@RequestBody Map<String, String> body) {
userService.getVerificationCode(body.get("account"), body.get("graphCode"));
return ResponseResult.success();
}
/ 校验邮箱/手机验证码 */
@PostMapping("code/verification/check")
ResponseResult checkVerificationCode(@RequestBody Map<String, String> body) {
return ResponseResult.success(userService.checkVerificationCode(body.get("account"), body.get("verificationCode")));
}
}
@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {
private final AuthenticationManager authenticationManager;
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
private final UserDao userDao;
private final JavaMailSender mailSender;
private final String emailRegex = "^([a-z0-9A-Z]+[-|.]?)+[a-z0-9A-Z]@([a-z0-9A-Z]+(-[a-z0-9A-Z]+)?\.)+[a-zA-Z]{2,}$";
private final String phoneRegex = "^((13[0-9])|(14[0,14-9])|(15[0-3,5-9])|(16[2,567])|(17[0-8])|(18[0-9])|(19[0-3,5-9]))\d{8}$";
@Overridepublic String getGraphCode(String account) { // 验证码的长、宽、验证码字符数、厚度 ShearCaptcha shearCaptcha = CaptchaUtil.createShearCaptcha(300, 100, 4, 5); // 将验证码保存到 redis,有效期 5 分钟 redisTemplate.opsForValue().set(RedisConst.USER_LOGGING_CODE_KEY + account, shearCaptcha.getCode(), RedisConst.USER_LOGGING_CODE_DURATION, TimeUnit.MILLISECONDS); // 返回验证码的 Base64 编码 return shearCaptcha.getImageBase64Data(); }
@Override
public void getVerificationCode(String account, String graphCode) {
// 判断是不是合法的 graphCode
if (graphCode.length() != 4) {
throw new CustomException(ExceptionEnum.WRPOMG_CODE);
}
String key = RedisConst.USER_LOGGING_CODE_KEY + account;
// 判断 graphCode 是否 1.过期 2.错误
if (!Optional.ofNullable(redisTemplate.opsForValue().get(key))
.orElseThrow(() -> new CustomException(ExceptionEnum.CODE_EXPIRED))
.equals(graphCode)) {
throw new CustomException(ExceptionEnum.WRPOMG_CODE);
}
String verificationCode = RandomUtil.randomNumbers(6);
// 将 verificationCode 存储到 redis,有效期 5 分钟
redisTemplate.opsForValue().set(key, verificationCode,
RedisConst.USER_LOGGING_CODE_DURATION, TimeUnit.MILLISECONDS);
System.out.println(verificationCode);
// 发送 verificationCode
if (account.matches(emailRegex)) {
MimeMessage message = mailSender.createMimeMessage();
try {
MimeMessageHelper messageHelper = new MimeMessageHelper(message, true);
// 邮件发送人
messageHelper.setFrom("hi1270778@foxmail.com");
// 邮件接收人,设置多个收件人地址
InternetAddress[] internetAddressTo = InternetAddress.parse(account);
messageHelper.setTo(internetAddressTo);
// 邮件主题
message.setSubject("湖中剑验证码");
// 邮件内容
messageHelper.setText("<h1>code:</h1>" + verificationCode, true);
// 发送
mailSender.send(message);
} catch (MessagingException e) {
throw new RuntimeException(e);
}
} else if (account.matches(phoneRegex)) {
// 依次是:地区 Id,Access Key Id,Access Key Secret
IAcsClient client = new DefaultAcsClient(DefaultProfile.getProfile(
"cn-beijing", "xxx", "xxx"));
// 封装 SendSmsRequest 对象
SendSmsRequest request = new SendSmsRequest();
request.setPhoneNumbers(account); // 接收短信的手机号码
request.setSignName("阿里云短信测试"); // 短信签名名称
request.setTemplateCode("SMS_154950909"); // 短信模板的 code
try {
// 设置模板中的变量的值
HashMap<String, String> param = new HashMap<>();
param.put("code", verificationCode);
request.setTemplateParam(objectMapper.writeValueAsString(param));
// 发送
client.getAcsResponse(request);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
} catch (ClientException e) {
log.error("ErrCode:" + e.getErrCode());
log.error("ErrMsg:" + e.getErrMsg());
log.error("RequestId:" + e.getRequestId());
throw new RuntimeException(e);
}
} else {
throw new CustomException(ExceptionEnum.INVALID_ACCOUNT);
}
log.info("account: " + account + "请求发送验证码");
}
@Override
public Map<String, String> checkVerificationCode(String account, String verificationCode) {
// 判断是不是合法的 verificationCode
if (verificationCode.length() != 6) {
throw new CustomException(ExceptionEnum.WRPOMG_CODE);
}
String key = RedisConst.USER_LOGGING_CODE_KEY + account;
// 判断 verificationCode 是否 1.过期 2.错误
if (!Optional.ofNullable(redisTemplate.opsForValue().get(key))
.orElseThrow(() -> new CustomException(ExceptionEnum.CODE_EXPIRED))
.equals(verificationCode)) {
throw new CustomException(ExceptionEnum.WRPOMG_CODE);
}
// 删除 redis 的 code key
redisTemplate.delete(key);
User user;
// 尝试去从 mssql 获取 user 信息
if (account.matches(emailRegex))
user = Optional.ofNullable(userDao.getUserByEmail(emailRegex))
.orElseGet(() -> {
User t = new User();
t.setEmail(account);
return t;
});
else
user = Optional.of(userDao.getUserByPhone(phoneRegex))
.orElseGet(() -> {
User t = new User();
t.setPhone(account);
return t;
});
// 如果是新 user,则将其保存到 mssql 中
Long userId = Optional.ofNullable(user.getId()).orElseGet(() -> {
userDao.create(user);
return user.getId();
});
// 获取 user 并保存到 redis 中
redisTemplate.opsForValue().set(RedisConst.USER_LOGGED_KEY + userId, user, RedisConst.USER_KEY_DURATION, TimeUnit.MILLISECONDS);
// 返回 jwts
return JwtUtils.createJWTs(userId.toString());
}
}
微信扫码登录
使用前要先在测试号填一个授权回调域名,需要包含服务器域名+端口。
大致流程如下:
为什么微信认证服务器不直接携带 access_token 去访问服务器呢?这是因为微信认证服务器是以 HTTP 请求去访问的后端服务器, HTTP 请求是不安全的,里面的 access_token 可能会被拦截进而导致用户信息的泄露。那为什么微信认证服务器不以 HTTPS 去访问后端服务器呢?这是因为不是所有后端服务器都支持 HTTPS 请求,为了通用性就只能用 HTTP 了。
实现代码如下:
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
@Service
@Component
@ServerEndpoint("/websocket")
@Slf4j
public class WebSocketServer {
private static final CopyOnWriteArraySet<WebSocketServer>
webSocketSet = new CopyOnWriteArraySet<>();
private Session session;
// 为什么加 static 呢? https://blog.csdn.net/j1231230/article/details/114641956
private static ObjectMapper objectMapper;
@Autowired
private void setObjectMapper(ObjectMapper objectMapper) {
WebSocketServer.objectMapper = objectMapper;
}
@OnOpen
public void onOpen(Session session) {
this.session = session;
webSocketSet.add(this);
// 把 sessionId 返回给前端
Map<String, String> map = new HashMap<>();
map.put("sessionId", session.getId());
sendMessage(ResponseResult.success(map));
log.info("id: " + session.getId() + "建立连接");
}
@OnClose
public void onClose() {
webSocketSet.remove(this);
log.info("id: " + session.getId() + "断开连接");
}
public void sendMessage(Object obj) {
try {
this.session.getBasicRemote().sendText(objectMapper.writeValueAsString(obj));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void sendMessage (String sessionId, Object obj) {
// 找到对应的 session 并发送信息
for (WebSocketServer ws : webSocketSet) {
System.out.println(ws.session.getId());
if (Objects.equals(ws.session.getId(), sessionId)) {
ws.sendMessage(obj);
break;
}
}
}
}
@RestController
@RequestMapping("login")
@RequiredArgsConstructor
public class LoginController {
private final UserService userService;
/* 获取微信扫码登录的二维码 /
@GetMapping("wx/code")
ResponseResult wxCode(String sessionId) {
return ResponseResult.success(userService.wxCode(sessionId));
}
/ 扫码成功后,微信服务器触发的回调 */
@GetMapping("wx/success/{sessionId}")
ResponseEntity<Object> wxSuccess(@PathVariable String sessionId, String code) {
return userService.wxSuccess(sessionId, code);
}
}
@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {
private final AuthenticationManager authenticationManager;
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
private final UserDao userDao;@Override public String wxCode(String sessionId) { try { // 这里带上 ws 的 sessionId,wx 登录成功后通过 ws 向前端发送 jwts String wxCallback = "https://open.weixin.qq.com/connect/oauth2/authorize?" + "appid=xxx&redirect_uri=" + URLEncoder.encode("http://xxx:xxx/login/wx/success/" + sessionId, "utf-8") + "&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect"; return QrCodeUtil.generateAsBase64(wxCallback, new QrConfig(300, 300), ImgUtil.IMAGE_TYPE_JPG); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e); } }
@Override
public ResponseEntity<Object> wxSuccess(String sessionId, String code) {
RestTemplate restTemplate = new RestTemplate();
/*
用 code 去换 wx 的 access_token
返回值的 Content-Type 是 text/plain,用不了 getForObject
故选择用 getForEntity,接收后手动转换
*/
Map<String, String> wxTokens;
try {
wxTokens = objectMapper.readValue(restTemplate.getForEntity("https://api.weixin.qq.com/sns/oauth2/access_token?" +
"appid=xxx8&secret=xxx8&code=" + code +
"&grant_type=authorization_code", String.class).getBody(),
new TypeReference<Map<String, String>>() {});
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
if (wxTokens == null || !StringUtils.hasText(wxTokens.get("access_token"))) {
throw new CustomException(ExceptionEnum.WX_LOG_FAILURE);
}
/* 用 access_token 去换用户信息 */
String accessToken = wxTokens.get("access_token");
String openId = wxTokens.get("openid");
Map<String, Object> wxUserInfo;
try {
wxUserInfo = objectMapper.readValue(restTemplate.getForEntity("https://api.weixin.qq.com/sns/userinfo?" +
"access_token=" + accessToken + "&openid=" + openId + "&lang=zh_CN", String.class).getBody(),
new TypeReference<Map<String, Object>>() {
});
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
if (wxUserInfo == null || wxUserInfo.get("openid") == null) {
throw new CustomException(ExceptionEnum.WX_LOG_FAILURE);
}
/* 封装从微信获取的个人信息 */
User user = Optional.ofNullable(userDao.getUserByWxId(openId)).orElse(new User());
user.setWxId(wxUserInfo.get("openid").toString());
user.setName(wxUserInfo.get("nickname").toString());
/* 如果是新 user,则将其保存到 mssql 中 */
Long userId = Optional.ofNullable(user.getId()).orElseGet(() -> {
userDao.create(user);
return user.getId();
});
/* 把用户头像保存到本地 */
String headImgUrl = (String) wxUserInfo.get("headimgurl");
try {
InputStream inputStream = new URL(headImgUrl).openConnection().getInputStream();
byte[] bs = new byte[1024];
int len;
String filename = "/a27/imgs/head_img_" + userId.toString() + ".jpg";
File file = new File(filename);
FileOutputStream os = new FileOutputStream(file, true);
while ((len = inputStream.read(bs)) != -1) {
os.write(bs, 0, len);
}
os.close();
inputStream.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
user.setHeadImg(wxUserInfo.get("headimgurl").toString());
user.setGender(wxUserInfo.get("sex").toString());
// 将 user 存储到 redis 中,有效期为 20 天
redisTemplate.opsForValue()
.set(RedisConst.USER_LOGGED_KEY + userId, user, RedisConst.USER_KEY_DURATION, TimeUnit.MILLISECONDS);
// 通过 websocket 把 jwt 传给前端
WebSocketServer.sendMessage(sessionId, ResponseResult.success(JwtUtils.createJWTs(userId.toString())));
// 重定向到微信登录成功页
return ResponseEntity.status(302)
.location(ServletUriComponentsBuilder.fromUriString("https://xxx:xxx/wx_success.html").build().toUri())
.build();
}
}
nginx 配置如下:
server {
listen 443 ssl;
server_name xxx;
ssl_certificate js.crt;
ssl_certificate_key js.key;
ssl_session_timeout 5m;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
ssl_prefer_server_ciphers on;
client_max_body_size 1024m;
location / {
root /usr/share/nginx/html;
index index.html;
charset utf-8;
try_files uri uri/ /index.html;
}
location /imgs/ {
root /a27/;
autoindex on;
}
location /wx/success {
root /usr/share/nginx/html;
index wx_success.html;
charset utf-8;
}
}