前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >SpringSecurity 实现几种常见的登录方式

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

原创
作者头像
1270778
修改2024-02-06 19:21:36
3520
修改2024-02-06 19:21:36

配置类

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("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());
}

工具类

代码语言: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 = "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 配置如下:

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 配置类
  • 认证流程
    • 基本原理
      • AuthenticationManager
        • PasswordEncoder
          • UserDetailsService
          • JWT
            • 为什么要用 JWT ?
              • 组成
                • 过期时间
                  • 工具类
                    • JWT 解析认证
                      • 自定义失败处理
                      • 账号密码登录
                      • 验证码登录
                      • 微信扫码登录
                      相关产品与服务
                      验证码
                      腾讯云新一代行为验证码(Captcha),基于十道安全栅栏, 为网页、App、小程序开发者打造立体、全面的人机验证。最大程度保护注册登录、活动秒杀、点赞发帖、数据保护等各大场景下业务安全的同时,提供更精细化的用户体验。
                      领券
                      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档