SpringSecurity 实现几种常见的登录方式

配置类

SpringSecurity 要求配置类继承 WebSecurityConfigurerAdapter,并重写其中的 configure 方法。我们先进行基本的配置:

代码语言:javascript
复制
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();
    }
}
  1. http.csrf().disable(),SpringSecurity 自带一个 CSRF 防御机制,这个机制要求我们在请求头中携带 X-CSRF-TOKEN,具体实现可以看:https://blog.csdn.net/fxtxz2/article/details/129496488。而 JWT 没有使用 cookie 存储,直接断了 CSRF 攻击的可能,所以我们也不需要做额外工作来使用 SpringSecurity 的 CSRF 防御机制了,直接把它关了就行。
  2. SessionCreationPolicy.STATELESS,SpringSecurity 不会创建 Session 也不会尝试去从 Session 获取 SecurityContext。我们的每次请求都会用 JWT 重新进行身份认证,不会依赖于之前的创建的 Session。
  3. 设置认证和放行的接口时,应注意:
    1. 接口一定要以 / 开头!!!
    2. 越具体的接口越先配置,这样也会被越先检查

认证流程

引入 SpringSecurity 后,SpringSecurity 会有一个默认的认证流程

  • Authentication 接口的实现类表示当前访问系统的用户,里面封装了用户相关的信息
  • AuthenticationManager 接口定理了认证 Authentication 的方法
  • UserDetailsService 接口定义了一个根据用户名查询出完整的用户信息的方法
  • UserDetails 接口提供了完整的用户信息

通过 UserDetailsService 查询出的用户信息要封装成 UserDetails 对象,再存入到 Authentication 对象中。

基本原理

HTTPS 会自动加密数据,防止被第三方窃听,所以前端可以直接传明文密码给后端,不需要做额外的安全工作。所以现在只需要保护好数据库中的密码,做到即使数据库泄露,黑客也无法成功登入系统。哈希类的函数可以很轻松的实现这个需求:

  1. 我们在数据库中存储 h(pwd)
  2. 校验时先从数据库取出 h(pwd),检查 h(pwd_input) == h(pwd),pwd_input 是前端传入的待校验的密码
  3. 校验通过时,给前端返回一个登录凭证:JWT(JSON Web Token)

在 SpringSecurity 中,h() 函数由 PasswordEncoder提供,取出 h(pwd) 的操作由 UserDetailsService 执行,整个校验过程由 AuthenticationManager 控制。校验成功后,用户的详细信息会被存入 SecurityContext,供其它业务方法访问。

AuthenticationManager

认证时需要调用 AuthenticationManager 的 authenticate 方法,所以我们需要将 AuthenticationManager 提前暴露出去。

代码语言:javascript
复制
@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 来进行密码校验。

代码语言:javascript
复制
@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    /* 其它的代码 ...... */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

BCryptPasswordEncoder 的 encode 方法每次在调用时都会生成一个随机的盐,在将盐与明文密码一起哈希,得到密文。当调用 matches 方法时,哈希过的盐值来判断传入的明文是否正确。

UserDetailsService

UserDetailsService 接口只有一个需要实现的方法: loadUserByUsername

  1. 入参为 username,但不是字面意义上的用户名,而是用户的一个唯一标识,所以邮箱、手机号、微信号等等都可以作为 username
  2. 返回一个 UserDetails 对象,也需要我们自己实现
代码语言:javascript
复制
@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 接口

代码语言:javascript
复制
@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 就可以了。

代码语言:javascript
复制
@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() 方法的代码如下:

代码语言:javascript
复制
@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;
        }
}
代码语言:javascript
复制
<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 由服务器签发,由三部分组成:

  1. Header(头部)
    1. 令牌(token)类型,JWT 令牌统一写 JWT
    2. 使用的签名算法
  2. Palyload(荷载),存放数据的 JSON 对象,JWT 提供了 7 个官方字段做参考
    1. iss (issuer):签发人
    2. exp (expiration time):过期时间
    3. sub (subject):主题,通常在这里存放用户数据
    4. aud (audience):受众
    5. nbf (Not Before):生效时间
    6. iat (Issued At):签发时间
    7. jti (JWT ID):编号
  3. Signature(签名),签名的计算需要一个密钥,这个密钥只有服务器拥有。使用 Header 中指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名:
代码语言:javascript
复制
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 的方案:

  1. 用户登录成功后签发两个 JWT:access_token 和 refresh_token
  2. access_token 的有效时间短,用于正常访问服务器
  3. refresh_token 的有效时间很长,用于在 access_token 过期时,去向服务器换取新的 access_token 和 refresh_token

刷新流程如下:

前端需要封装下 Axios:

代码语言:javascript
复制
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
})

后端需要提供一个刷新接口:

代码语言:javascript
复制
@RestController
@RequestMapping("login")
@RequiredArgsConstructor
public class LoginController {
​
    private final UserService userService;
/* 以 refresh_token 去获取新的 access_token 和 refresh_token  */
@GetMapping(&#34;refresh&#34;)
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());
}

工具类

代码语言:javascript
复制
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 解析认证过滤器:

代码语言:javascript
复制
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 即可实现自定义失败处理。

代码语言:javascript
复制
@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() + " 授权失败");
});
}
}

账号密码登录

有了上面的代码基础,我们就可以很轻松的实现一个邮箱/手机号 + 密码登录功能。

代码语言:javascript
复制
@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());
}
}

验证码登录

短信和邮箱需要导入以下依赖

代码语言:javascript
复制
<!-- 阿里短信服务 -->
<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>

大致流程如下:

代码实现如下:

代码语言:javascript
复制
@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}$";
@Override

public 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 了。

实现代码如下:

代码语言:javascript
复制
@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 = &#34;https://open.weixin.qq.com/connect/oauth2/authorize?&#34; +
                &#34;appid=xxx&amp;redirect_uri=&#34; +
                URLEncoder.encode(&#34;http://xxx:xxx/login/wx/success/&#34; + sessionId, &#34;utf-8&#34;) +
                &#34;&amp;response_type=code&amp;scope=snsapi_userinfo&amp;state=STATE#wechat_redirect&#34;;
        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 配置如下:

代码语言:javascript
复制
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;
}
}