refactor: 项目包结构优化

This commit is contained in:
Ray.Hao
2026-07-31 00:21:49 +08:00
parent a4b699a109
commit 07616d9118
144 changed files with 1410 additions and 1935 deletions

View File

@@ -1,13 +1,13 @@
package com.youlai.boot.auth.controller;
import com.youlai.boot.auth.model.form.LoginForm;
import com.youlai.boot.auth.model.req.LoginReq;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.auth.service.AuthService;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.framework.annotation.RateLimit;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.auth.model.resp.CaptchaInfo;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -49,32 +49,11 @@ public class AuthController {
@PostMapping("/login")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
@RateLimit
public Result<AuthenticationToken> login(@RequestBody @Valid LoginForm request) {
public Result<AuthenticationToken> login(@RequestBody @Valid LoginReq request) {
AuthenticationToken authenticationToken = authService.login(request.getUsername(), request.getPassword());
return Result.success(authenticationToken);
}
@Operation(summary = "短信验证码登录")
@PostMapping("/login/sms")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
public Result<AuthenticationToken> loginBySms(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile,
@Parameter(description = "验证码", example = "123456") @RequestParam String code
) {
AuthenticationToken loginResult = authService.loginBySms(mobile, code);
return Result.success(loginResult);
}
@Operation(summary = "发送登录短信验证码")
@PostMapping("/sms/code")
@RateLimit(limit = 1, window = 60)
public Result<Void> sendSmsCode(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile
) {
authService.sendSmsCode(mobile);
return Result.success();
}
@Operation(summary = "退出登录")
@DeleteMapping("/logout")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGOUT)

View File

@@ -0,0 +1,96 @@
package com.youlai.boot.auth.controller;
import com.youlai.boot.auth.model.req.QrCodeTicketReq;
import com.youlai.boot.auth.model.resp.QrCodeGenerateResp;
import com.youlai.boot.auth.model.resp.QrCodeStatusResp;
import com.youlai.boot.auth.service.QrCodeLoginService;
import com.youlai.boot.common.util.IPUtils;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.util.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 扫码登录认证接口
*
* <p>generate/status/login 不需要登录态PC 端未登录),由 Security 配置放行;
* scan/confirm/cancel 需要 APP 端登录态,当前用户 ID 从 Security 上下文获取。</p>
*
* @author Ray.Hao
* @since 4.5.0
*/
@Tag(name = "01.认证中心")
@RestController
@RequestMapping("/api/v1/auth/qr-code")
@RequiredArgsConstructor
public class QrCodeAuthController {
private final QrCodeLoginService qrCodeLoginService;
/**
* 生成扫码登录票据
*/
@Operation(summary = "[扫码]生成扫码登录票据")
@PostMapping("/generate")
@RateLimit(limit = 30, window = 60)
public Result<QrCodeGenerateResp> generate(HttpServletRequest request) {
return Result.success(qrCodeLoginService.generate(IPUtils.getIpAddr(request)));
}
/**
* 查询扫码状态
*/
@Operation(summary = "[扫码]查询扫码状态")
@GetMapping("/status")
@RateLimit(limit = 60, window = 60)
public Result<QrCodeStatusResp> status(@RequestParam String ticket) {
return Result.success(qrCodeLoginService.status(ticket));
}
/**
* APP 标记已扫码
*/
@Operation(summary = "[扫码]APP 标记已扫码")
@PostMapping("/scan")
public Result<QrCodeStatusResp> scan(@RequestBody @Valid QrCodeTicketReq form) {
return Result.success(qrCodeLoginService.scan(form.getTicket(), SecurityUtils.getUserId()));
}
/**
* APP 确认登录
*/
@Operation(summary = "[扫码]APP 确认登录")
@PostMapping("/confirm")
public Result<QrCodeStatusResp> confirm(@RequestBody @Valid QrCodeTicketReq form) {
return Result.success(qrCodeLoginService.confirm(form.getTicket(), SecurityUtils.getUserId()));
}
/**
* APP 取消登录
*/
@Operation(summary = "[扫码]APP 取消登录")
@PostMapping("/cancel")
public Result<QrCodeStatusResp> cancel(@RequestBody @Valid QrCodeTicketReq form) {
return Result.success(qrCodeLoginService.cancel(form.getTicket(), SecurityUtils.getUserId()));
}
/**
* PC 端用票据换取会话令牌
*/
@Operation(summary = "[扫码]PC 端换取会话令牌")
@PostMapping("/login")
public Result<AuthenticationToken> login(@RequestBody @Valid QrCodeTicketReq form) {
return Result.success(qrCodeLoginService.login(form.getTicket()));
}
}

View File

@@ -0,0 +1,56 @@
package com.youlai.boot.auth.controller;
import com.youlai.boot.auth.service.AuthService;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 短信验证码认证接口
*
* @author Ray.Hao
* @since 4.5.0
*/
@Tag(name = "01.认证中心")
@RestController
@RequestMapping("/api/v1/auth/sms")
@RequiredArgsConstructor
@Slf4j
public class SmsAuthController {
private final AuthService authService;
@Operation(summary = "[短信]发送登录短信验证码")
@PostMapping("/code")
@RateLimit(limit = 1, window = 60)
public Result<Void> sendCode(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile
) {
authService.sendSmsCode(mobile);
return Result.success();
}
@Operation(summary = "[短信]短信验证码登录")
@PostMapping("/login")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
public Result<AuthenticationToken> login(
@Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile,
@Parameter(description = "验证码", example = "123456") @RequestParam String code
) {
AuthenticationToken loginResult = authService.loginBySms(mobile, code);
return Result.success(loginResult);
}
}

View File

@@ -1,11 +1,11 @@
package com.youlai.boot.auth.controller;
import com.youlai.boot.auth.model.form.WxMaBindMobileForm;
import com.youlai.boot.auth.model.form.WxMaPhoneLoginForm;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import com.youlai.boot.auth.model.req.WxMaBindMobileReq;
import com.youlai.boot.auth.model.req.WxMaPhoneLoginReq;
import com.youlai.boot.auth.model.resp.WxMaLoginResp;
import com.youlai.boot.auth.service.WxMaAuthService;
import com.youlai.boot.framework.annotation.Log;
import com.youlai.boot.framework.annotation.RateLimit;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.common.enums.ActionTypeEnum;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.common.result.Result;
@@ -29,9 +29,9 @@ import jakarta.validation.Valid;
* @author Ray.Hao
* @since 2.4.0
*/
@Tag(name = "13.微信小程序认证")
@Tag(name = "01.认证中心")
@RestController
@RequestMapping("/api/v1/wxma/auth")
@RequestMapping("/api/v1/auth/wxma")
@RequiredArgsConstructor
@Slf4j
public class WxMaAuthController {
@@ -47,15 +47,15 @@ public class WxMaAuthController {
* <li>未绑定手机号的用户:返回 openid需调用绑定手机号接口</li>
* </ul>
*/
@Operation(summary = "静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token未绑定用户返回 openid 需绑定手机号")
@Operation(summary = "[小程序]静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token未绑定用户返回 openid 需绑定手机号")
@PostMapping("/silent-login")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
@RateLimit
public Result<WxMaLoginVO> silentLogin(
public Result<WxMaLoginResp> silentLogin(
@Parameter(description = "微信登录凭证wx.login 获取)", required = true, example = "0xxx")
@RequestParam String code
) {
WxMaLoginVO result = wxMaAuthService.silentLogin(code);
WxMaLoginResp result = wxMaAuthService.silentLogin(code);
return Result.success(result);
}
@@ -66,11 +66,11 @@ public class WxMaAuthController {
* <p>
* 一步完成登录,无需绑定流程,自动创建新用户
*/
@Operation(summary = "手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序")
@Operation(summary = "[小程序]手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序")
@PostMapping("/phone-login")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
@RateLimit
public Result<AuthenticationToken> phoneLogin(@Valid @RequestBody WxMaPhoneLoginForm req) {
public Result<AuthenticationToken> phoneLogin(@Valid @RequestBody WxMaPhoneLoginReq req) {
AuthenticationToken result = wxMaAuthService.phoneLogin(req.getLoginCode(), req.getPhoneCode());
return Result.success(result);
}
@@ -82,11 +82,11 @@ public class WxMaAuthController {
* <p>
* 绑定成功后自动完成登录
*/
@Operation(summary = "绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录")
@Operation(summary = "[小程序]绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录")
@PostMapping("/bind-mobile")
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
@RateLimit
public Result<AuthenticationToken> bindMobile(@Valid @RequestBody WxMaBindMobileForm req) {
public Result<AuthenticationToken> bindMobile(@Valid @RequestBody WxMaBindMobileReq req) {
AuthenticationToken result = wxMaAuthService.bindMobile(req.getOpenid(), req.getMobile(), req.getSmsCode());
return Result.success(result);
}

View File

@@ -0,0 +1,14 @@
package com.youlai.boot.auth.enums;
/**
* EasyCaptcha 验证码类型
*
* @author haoxr
* @since 2.5.1
*/
public enum CaptchaTypeEnum {
CIRCLE,
GIF,
LINE,
SHEAR
}

View File

@@ -0,0 +1,28 @@
package com.youlai.boot.auth.enums;
/**
* 扫码登录票据状态
*
* @author Ray.Hao
* @since 4.5.0
*/
public enum QrCodeLoginStatusEnum {
/** 票据已创建,等待 APP 扫码 */
WAITING,
/** APP 已扫码,等待用户在手机上确认 */
SCANNED,
/** 用户已在 APP 上确认登录 */
CONFIRMED,
/** PC 已用票据换取会话令牌,票据作废,不可再用 */
LOGGED_IN,
/** 用户在 APP 上取消登录 */
CANCELED,
/** 票据超时,由 Redis TTL 自动清理 */
EXPIRED
}

View File

@@ -0,0 +1,24 @@
package com.youlai.boot.auth.exception;
import com.youlai.boot.common.result.ResultCode;
import lombok.Getter;
/**
* 图形验证码校验失败时抛出的异常。
* <p>
* 携带 {@link ResultCode} 用于统一错误响应。
*
* @author Ray.Hao
* @since 3.1.0
*/
@Getter
public class CaptchaException extends RuntimeException {
private final ResultCode resultCode;
public CaptchaException(ResultCode resultCode) {
super(resultCode.getMsg());
this.resultCode = resultCode;
}
}

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.auth.security.exception;
package com.youlai.boot.auth.exception;
import org.springframework.security.core.AuthenticationException;

View File

@@ -1,11 +1,12 @@
package com.youlai.boot.auth.qrcode.model;
package com.youlai.boot.auth.model.dto;
import lombok.Data;
/**
* 扫码登录票据上下文序列化为 JSON 存入 Redis
* <p>
* 字段说明见 docs/youlai-boot/scan-code-login.md Redis 存储设计
* 扫码登录票据上下文序列化为 JSON 存入 Redis
*
* @author Ray.Hao
* @since 4.5.0
*/
@Data
public class QrCodeLoginContext {
@@ -13,7 +14,7 @@ public class QrCodeLoginContext {
/** 票据UUID 无连字符 */
private String ticket;
/** 状态枚举名,取 {@link QrCodeLoginStatusEnum#name()} */
/** 状态枚举名,取 {@link com.youlai.boot.auth.enums.QrCodeLoginStatusEnum#name()} */
private String status;
/** 扫码用户 IDscan 时写入 */
@@ -34,6 +35,6 @@ public class QrCodeLoginContext {
/** 确认时间戳(毫秒) */
private Long confirmedAt;
/** generate 时的 PC 端 IP,用于审计 */
/** generate 时的 PC 端 IP */
private String clientIp;
}

View File

@@ -1,15 +1,15 @@
package com.youlai.boot.auth.model.form;
package com.youlai.boot.auth.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 登录表单
* 登录请求参数
*/
@Schema(description = "登录请求参数")
@Data
public class LoginForm {
public class LoginReq {
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
@NotBlank(message = "用户名不能为空")

View File

@@ -0,0 +1,17 @@
package com.youlai.boot.auth.model.req;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 扫码登录票据请求,用于 scan/confirm/cancel/login 接口
*
* @author Ray.Hao
* @since 4.5.0
*/
@Data
public class QrCodeTicketReq {
@NotBlank(message = "票据不能为空")
private String ticket;
}

View File

@@ -1,15 +1,15 @@
package com.youlai.boot.auth.model.form;
package com.youlai.boot.auth.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 微信小程序绑定手机号表单
* 微信小程序绑定手机号请求
*/
@Schema(description = "微信小程序绑定手机号请求")
@Data
public class WxMaBindMobileForm {
public class WxMaBindMobileReq {
@NotBlank(message = "openid 不能为空")
@Schema(description = "微信用户唯一标识", example = "oVBkZ0aYgDMDIywRdgPW8-joxXc4")

View File

@@ -1,15 +1,15 @@
package com.youlai.boot.auth.model.form;
package com.youlai.boot.auth.model.req;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 微信小程序手机号快捷登录表单
* 微信小程序手机号快捷登录请求
*/
@Schema(description = "微信小程序手机号快捷登录请求")
@Data
public class WxMaPhoneLoginForm {
public class WxMaPhoneLoginReq {
@NotBlank(message = "微信登录凭证不能为空")
@Schema(description = "微信登录凭证wx.login 获取)", example = "0xxx")

View File

@@ -0,0 +1,25 @@
package com.youlai.boot.auth.model.resp;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 验证码信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "验证码信息")
public class CaptchaInfo {
@Schema(description = "验证码缓存ID")
private String captchaId;
@Schema(description = "验证码图片Base64字符串")
private String captchaBase64;
}

View File

@@ -1,16 +1,19 @@
package com.youlai.boot.auth.qrcode.model.vo;
package com.youlai.boot.auth.model.resp;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
/**
* generate 接口响应
* generate 接口响应
*
* @author Ray.Hao
* @since 4.5.0
*/
@Data
@Builder
@Schema(description = "扫码票据生成结果")
public class QrCodeGenerateVO {
public class QrCodeGenerateResp {
@Schema(description = "票据")
private String ticket;

View File

@@ -1,16 +1,19 @@
package com.youlai.boot.auth.qrcode.model.vo;
package com.youlai.boot.auth.model.resp;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
/**
* status/scan/confirm/cancel 接口响应
* status/scan/confirm/cancel 接口响应
*
* @author Ray.Hao
* @since 4.5.0
*/
@Data
@Builder
@Schema(description = "扫码登录状态")
public class QrCodeStatusVO {
public class QrCodeStatusResp {
@Schema(description = "票据")
private String ticket;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.auth.model.vo;
package com.youlai.boot.auth.model.resp;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
@@ -14,7 +14,7 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "微信小程序登录响应")
public class WxMaLoginVO {
public class WxMaLoginResp {
@Schema(description = "是否新用户")
private Boolean isNewUser;

View File

@@ -1,89 +0,0 @@
package com.youlai.boot.auth.qrcode.controller;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.qrcode.model.form.QrCodeTicketForm;
import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
import com.youlai.boot.auth.qrcode.service.QrCodeLoginService;
import com.youlai.boot.framework.annotation.RateLimit;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.util.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 扫码登录接口。
* <p>
* generate/status/login 不需要登录态PC 端未登录),由 Security 配置放行;
* scan/confirm/cancel 需要 APP 端登录态,当前用户 ID 从 Security 上下文获取。
*/
@Tag(name = "02.扫码登录")
@RestController
@RequestMapping("/api/v1/auth/qr-code")
@RequiredArgsConstructor
public class QrCodeLoginController {
private final QrCodeLoginService qrCodeLoginService;
@Operation(summary = "生成扫码登录票据")
@PostMapping("/generate")
@RateLimit(limit = 30, window = 60)
public Result<QrCodeGenerateVO> generate(HttpServletRequest request) {
return Result.success(qrCodeLoginService.generate(getClientIp(request)));
}
@Operation(summary = "查询扫码状态")
@GetMapping("/status")
@RateLimit(limit = 60, window = 60)
public Result<QrCodeStatusVO> status(@RequestParam String ticket) {
return Result.success(qrCodeLoginService.status(ticket));
}
@Operation(summary = "APP 标记已扫码")
@PostMapping("/scan")
public Result<QrCodeStatusVO> scan(@RequestBody @Valid QrCodeTicketForm form) {
return Result.success(qrCodeLoginService.scan(form.getTicket(), SecurityUtils.getUserId()));
}
@Operation(summary = "APP 确认登录")
@PostMapping("/confirm")
public Result<QrCodeStatusVO> confirm(@RequestBody @Valid QrCodeTicketForm form) {
return Result.success(qrCodeLoginService.confirm(form.getTicket(), SecurityUtils.getUserId()));
}
@Operation(summary = "APP 取消登录")
@PostMapping("/cancel")
public Result<QrCodeStatusVO> cancel(@RequestBody @Valid QrCodeTicketForm form) {
return Result.success(qrCodeLoginService.cancel(form.getTicket(), SecurityUtils.getUserId()));
}
@Operation(summary = "PC 端换取会话令牌")
@PostMapping("/login")
public Result<AuthenticationToken> login(@RequestBody @Valid QrCodeTicketForm form) {
return Result.success(qrCodeLoginService.login(form.getTicket()));
}
/** 从请求头或连接信息中提取客户端 IP兼容反向代理 */
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
int comma = ip.indexOf(',');
return comma > 0 ? ip.substring(0, comma).trim() : ip.trim();
}
ip = request.getHeader("X-Real-IP");
if (StrUtil.isNotBlank(ip) && !"unknown".equalsIgnoreCase(ip)) {
return ip.trim();
}
return request.getRemoteAddr();
}
}

View File

@@ -1,21 +0,0 @@
package com.youlai.boot.auth.qrcode.model;
/**
* 扫码登录票据状态。
* <p>
* WAITING 票据已创建,等待 APP 扫码
* SCANNED APP 已扫码,等待用户在手机上确认
* CONFIRMED 用户已在 APP 上确认登录
* LOGGED_IN PC 已用票据换取会话令牌,票据作废,不可再用
* CANCELED 用户在 APP 上取消登录
* EXPIRED 票据超时,由 Redis TTL 自动清理,内存中通常不会出现该值
*/
public enum QrCodeLoginStatusEnum {
WAITING,
SCANNED,
CONFIRMED,
LOGGED_IN,
CANCELED,
EXPIRED
}

View File

@@ -1,14 +0,0 @@
package com.youlai.boot.auth.qrcode.model.form;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 扫码登录票据表单,用于 scan/confirm/cancel/login 接口。
*/
@Data
public class QrCodeTicketForm {
@NotBlank(message = "票据不能为空")
private String ticket;
}

View File

@@ -1,29 +0,0 @@
package com.youlai.boot.auth.qrcode.service;
import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
import com.youlai.boot.framework.security.model.AuthenticationToken;
/**
* 扫码登录服务。
*/
public interface QrCodeLoginService {
/** 生成票据,未登录调用 */
QrCodeGenerateVO generate(String clientIp);
/** 查询状态,未登录调用 */
QrCodeStatusVO status(String ticket);
/** APP 标记已扫码,需 APP 端已登录 */
QrCodeStatusVO scan(String ticket, Long userId);
/** APP 确认登录,需 APP 端已登录 */
QrCodeStatusVO confirm(String ticket, Long userId);
/** APP 取消登录,需 APP 端已登录 */
QrCodeStatusVO cancel(String ticket, Long userId);
/** PC 端用票据换取会话令牌,未登录调用 */
AuthenticationToken login(String ticket);
}

View File

@@ -1,34 +0,0 @@
package com.youlai.boot.auth.qrcode.util;
/**
* 昵称脱敏:保留首尾各一个字符,中间用 * 替换。
* <p>
* 长度 1原样返回
* 长度 2首字 + *
* 长度 ≥3首字 + (n-2) 个 * + 末字
*/
public final class QrCodeNicknameMasker {
private QrCodeNicknameMasker() {
}
public static String mask(String nickname) {
if (nickname == null || nickname.isEmpty()) {
return "";
}
int len = nickname.length();
if (len == 1) {
return nickname;
}
if (len == 2) {
return nickname.charAt(0) + "*";
}
StringBuilder sb = new StringBuilder();
sb.append(nickname.charAt(0));
for (int i = 0; i < len - 2; i++) {
sb.append('*');
}
sb.append(nickname.charAt(len - 1));
return sb.toString();
}
}

View File

@@ -1,131 +0,0 @@
package com.youlai.boot.auth.security.config;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.hutool.core.util.ArrayUtil;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import com.youlai.boot.framework.security.config.SecurityProperties;
import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.framework.security.service.SecurityUserDetailsService;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.auth.security.filter.CaptchaValidationFilter;
import com.youlai.boot.auth.security.handler.JsonAccessDeniedHandler;
import com.youlai.boot.auth.security.handler.JsonAuthenticationEntryPoint;
import com.youlai.boot.auth.security.provider.SmsAuthenticationProvider;
import com.youlai.boot.auth.security.provider.WxMaAuthenticationProvider;
import com.youlai.boot.system.service.UserSocialService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
/**
* Spring Security 配置类。
* <p>
* 归使用方auth 模块安全规则放行路径、CORS、Provider 装配、响应格式)
* 因项目而异,不应由框架层强制装配。
*
* @author Ray.Hao
* @since 4.3.1
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final RedisTemplate<String, Object> redisTemplate;
private final PasswordEncoder passwordEncoder;
private final TokenManager tokenManager;
private final SecurityUserDetailsService userDetailsService;
private final CaptchaService captchaService;
private final SecurityProperties securityProperties;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(requestMatcherRegistry -> {
String[] ignoreUrls = securityProperties.getIgnoreUrls();
if (ArrayUtil.isNotEmpty(ignoreUrls)) {
requestMatcherRegistry.requestMatchers(ignoreUrls).permitAll();
}
requestMatcherRegistry.anyRequest().authenticated();
}
)
.exceptionHandling(configurer ->
configurer
.authenticationEntryPoint(new JsonAuthenticationEntryPoint())
.accessDeniedHandler(new JsonAccessDeniedHandler())
)
.sessionManagement(configurer ->
configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
// 验证码校验(使用方过滤器,直接写 JSON 响应)
.addFilterBefore(new CaptchaValidationFilter(captchaService), UsernamePasswordAuthenticationFilter.class)
// Token 认证Starter 过滤器,抛 AuthenticationException 交给 ExceptionTranslationFilter 处理)
.addFilterBefore(new TokenAuthenticationFilter(tokenManager), AuthorizationFilter.class)
.build();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (web) -> {
String[] unsecuredUrls = securityProperties.getUnsecuredUrls();
if (ArrayUtil.isNotEmpty(unsecuredUrls)) {
web.ignoring().requestMatchers(unsecuredUrls);
}
};
}
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService);
provider.setPasswordEncoder(passwordEncoder);
return provider;
}
@Bean
public SmsAuthenticationProvider smsAuthenticationProvider(UserAuthenticationPort userAuthPort) {
return new SmsAuthenticationProvider(userAuthPort, redisTemplate);
}
@Bean
public WxMaAuthenticationProvider wechatMiniAuthenticationProvider(
WxMaService wxMaService,
UserAuthenticationPort userAuthenticationPort,
UserSocialService userSocialService
) {
return new WxMaAuthenticationProvider(wxMaService, userAuthenticationPort, userSocialService);
}
@Bean
public AuthenticationManager authenticationManager(
DaoAuthenticationProvider daoAuthenticationProvider,
SmsAuthenticationProvider smsAuthenticationProvider,
WxMaAuthenticationProvider wxMaAuthenticationProvider
) {
return new ProviderManager(
daoAuthenticationProvider,
smsAuthenticationProvider,
wxMaAuthenticationProvider
);
}
}

View File

@@ -1,26 +0,0 @@
package com.youlai.boot.auth.security.exception;
import org.springframework.security.core.AuthenticationException;
/**
* 需要绑定手机号异常(微信小程序登录未绑定手机号时抛出)。
*/
public class MobileNotBoundException extends AuthenticationException {
private final String openid;
private final String sessionKey;
public MobileNotBoundException(String openid, String sessionKey) {
super("需要绑定手机号");
this.openid = openid;
this.sessionKey = sessionKey;
}
public String getOpenid() {
return openid;
}
public String getSessionKey() {
return sessionKey;
}
}

View File

@@ -6,8 +6,8 @@ import cn.hutool.json.JSONUtil;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.web.util.ResponseWriter;
import com.youlai.boot.framework.captcha.exception.CaptchaException;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import com.youlai.boot.auth.exception.CaptchaException;
import com.youlai.boot.auth.service.CaptchaService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;

View File

@@ -6,8 +6,8 @@ import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.auth.security.exception.SmsCaptchaException;
import com.youlai.boot.auth.security.model.SmsAuthenticationToken;
import com.youlai.boot.auth.exception.SmsCaptchaException;
import com.youlai.boot.auth.security.token.SmsAuthenticationToken;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationProvider;

View File

@@ -7,8 +7,8 @@ import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.port.UserAuthenticationPort;
import com.youlai.boot.auth.security.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.model.WxMaAuthenticationToken;
import com.youlai.boot.common.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.token.WxMaAuthenticationToken;
import com.youlai.boot.system.model.entity.UserSocial;
import com.youlai.boot.system.service.UserSocialService;
import lombok.RequiredArgsConstructor;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.auth.security.model;
package com.youlai.boot.auth.security.token;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

View File

@@ -1,4 +1,4 @@
package com.youlai.boot.auth.security.model;
package com.youlai.boot.auth.security.token;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

View File

@@ -1,56 +1,143 @@
package com.youlai.boot.auth.service;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.auth.model.resp.CaptchaInfo;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.auth.security.token.SmsAuthenticationToken;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.support.sms.SmsTypeEnum;
import com.youlai.boot.support.sms.SmsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 认证服务接口
* 认证服务
*
* @author Ray.Hao
* @since 2.4.0
*/
public interface AuthService {
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthService {
private final AuthenticationManager authenticationManager;
private final TokenManager tokenManager;
private final SmsService smsService;
private final RedisTemplate<String, Object> redisTemplate;
private final CaptchaService captchaService;
/**
* 账号密码登录
* 用户名密码登录
*
* @param username 用户名
* @param password 密码
* @return 认证令牌
* @return 访问令牌
*/
AuthenticationToken login(String username, String password);
public AuthenticationToken login(String username, String password) {
// 1. 创建用于密码认证的令牌(未认证)
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username.trim(), password);
// 2. 执行认证(认证中)
// 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。
// 默认情况下会委托给 DaoAuthenticationProvider
// 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SecurityUserDetailsService 实现)
// 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配)
// 认证通过后返回已认证的 Authenticationprincipal 为 SecurityUserDetailsauthorities 为角色/权限集合)。
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
AuthenticationToken authenticationTokenResponse =
tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authenticationTokenResponse;
}
/**
* 发送登录短信验证码
*
* @param mobile 手机号
*/
public void sendSmsCode(String mobile) {
// 随机生成4位验证码
// String code = String.valueOf((int) ((Math.random() * 9 + 1) * 1000));
// TODO 为了方便测试,验证码固定为 1234实际开发中在配置了厂商短信服务后可以使用上面的随机验证码
String code = "1234";
// 发送短信验证码
Map<String, String> templateParams = new HashMap<>();
templateParams.put("code", code);
try {
smsService.send(mobile, SmsTypeEnum.LOGIN, templateParams);
} catch (Exception e) {
log.error("发送短信验证码失败", e);
}
// 缓存验证码至Redis用于登录校验
redisTemplate.opsForValue().set(StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile), code, 5, TimeUnit.MINUTES);
}
/**
* 短信验证码登录
*
* @param mobile 手机号
* @param code 验证码
* @return 认证令牌
* @return 访问令牌
*/
AuthenticationToken loginBySms(String mobile, String code);
public AuthenticationToken loginBySms(String mobile, String code) {
// 1. 创建用户短信验证码认证的令牌(未认证)
SmsAuthenticationToken smsAuthenticationToken = new SmsAuthenticationToken(mobile, code);
// 2. 执行认证(认证中)
Authentication authentication = authenticationManager.authenticate(smsAuthenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
AuthenticationToken authenticationToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authenticationToken;
}
/**
* 发送短信验证码
*
* @param mobile 手机号
* 注销登录
*/
void sendSmsCode(String mobile);
/**
* 退出登录
*/
void logout();
public void logout() {
String token = SecurityUtils.getAccessToken();
if (StrUtil.isNotBlank(token)) {
tokenManager.invalidateToken(token);
// 清除Security上下文
SecurityContextHolder.clearContext();
}
}
/**
* 获取验证码
*/
CaptchaInfo getCaptcha();
public CaptchaInfo getCaptcha() {
return captchaService.generate();
}
/**
* 刷新令牌
* 刷新token
*
* @param refreshToken 刷新令牌
* @return 认证令牌
* @return 新的访问令牌
*/
AuthenticationToken refreshToken(String refreshToken);
public AuthenticationToken refreshToken(String refreshToken) {
return tokenManager.refreshToken(refreshToken);
}
}

View File

@@ -0,0 +1,105 @@
package com.youlai.boot.auth.service;
import cn.hutool.captcha.AbstractCaptcha;
import cn.hutool.captcha.CaptchaUtil;
import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.auth.enums.CaptchaTypeEnum;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.config.property.CaptchaProperties;
import com.youlai.boot.auth.exception.CaptchaException;
import com.youlai.boot.auth.model.resp.CaptchaInfo;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.awt.Font;
import java.util.concurrent.TimeUnit;
/**
* 验证码服务
*
* @author Ray.Hao
* @since 3.1.0
*/
@Service
@RequiredArgsConstructor
public class CaptchaService {
private final RedisTemplate<String, Object> redisTemplate;
private final CaptchaProperties captchaProperties;
private final CodeGenerator codeGenerator;
private final Font captchaFont;
/**
* 生成验证码
*/
public CaptchaInfo generate() {
String captchaType = captchaProperties.getType();
int width = captchaProperties.getWidth();
int height = captchaProperties.getHeight();
int interfereCount = captchaProperties.getInterfereCount();
int codeLength = captchaProperties.getCode().getLength();
AbstractCaptcha captcha;
if (CaptchaTypeEnum.CIRCLE.name().equalsIgnoreCase(captchaType)) {
captcha = CaptchaUtil.createCircleCaptcha(width, height, codeLength, interfereCount);
} else if (CaptchaTypeEnum.GIF.name().equalsIgnoreCase(captchaType)) {
captcha = CaptchaUtil.createGifCaptcha(width, height, codeLength);
} else if (CaptchaTypeEnum.LINE.name().equalsIgnoreCase(captchaType)) {
captcha = CaptchaUtil.createLineCaptcha(width, height, codeLength, interfereCount);
} else if (CaptchaTypeEnum.SHEAR.name().equalsIgnoreCase(captchaType)) {
captcha = CaptchaUtil.createShearCaptcha(width, height, codeLength, interfereCount);
} else {
throw new IllegalArgumentException("Invalid captcha type: " + captchaType);
}
captcha.setGenerator(codeGenerator);
captcha.setTextAlpha(captchaProperties.getTextAlpha());
captcha.setFont(captchaFont);
String captchaCode = captcha.getCode();
String imageBase64Data = captcha.getImageBase64Data();
String captchaId = IdUtil.fastSimpleUUID();
redisTemplate.opsForValue().set(
StrUtil.format(RedisConstants.Captcha.IMAGE_CODE, captchaId),
captchaCode,
captchaProperties.getExpireSeconds(),
TimeUnit.SECONDS
);
return CaptchaInfo.builder()
.captchaId(captchaId)
.captchaBase64(imageBase64Data)
.build();
}
/**
* 校验验证码,失败抛异常
*
* @param captchaId 验证码ID
* @param captchaCode 用户输入的验证码
* @throws CaptchaException 验证码错误或过期
*/
public void validate(String captchaId, String captchaCode) {
if (StrUtil.isBlank(captchaId) || StrUtil.isBlank(captchaCode)) {
throw new CaptchaException(ResultCode.USER_VERIFICATION_CODE_ERROR);
}
String cacheKey = StrUtil.format(RedisConstants.Captcha.IMAGE_CODE, captchaId);
String cachedCode = (String) redisTemplate.opsForValue().get(cacheKey);
if (cachedCode == null) {
throw new CaptchaException(ResultCode.USER_VERIFICATION_CODE_EXPIRED);
}
if (!codeGenerator.verify(cachedCode, captchaCode)) {
throw new CaptchaException(ResultCode.USER_VERIFICATION_CODE_ERROR);
}
redisTemplate.delete(cacheKey);
}
}

View File

@@ -1,16 +1,14 @@
package com.youlai.boot.auth.qrcode.service.impl;
package com.youlai.boot.auth.service;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.qrcode.model.QrCodeLoginContext;
import com.youlai.boot.auth.qrcode.model.QrCodeLoginStatusEnum;
import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO;
import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO;
import com.youlai.boot.auth.qrcode.service.QrCodeLoginService;
import com.youlai.boot.auth.qrcode.util.QrCodeNicknameMasker;
import com.youlai.boot.auth.enums.QrCodeLoginStatusEnum;
import com.youlai.boot.auth.model.dto.QrCodeLoginContext;
import com.youlai.boot.auth.model.resp.QrCodeGenerateResp;
import com.youlai.boot.auth.model.resp.QrCodeStatusResp;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import com.youlai.boot.common.exception.TokenInvalidException;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.SecurityUser;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
@@ -27,23 +25,20 @@ import tools.jackson.databind.json.JsonMapper;
import java.util.concurrent.TimeUnit;
/**
* 扫码登录服务实现
* <p>
* 票据上下文以 JSON 形式存入 RedisTTL 与有效期一致状态流转的合法性全部由
* {@link #requireStatus} / {@link #requireSameUser} 在写入前拦截非法迁移直接抛业务异常
* 因此同一 ticket 被多端并发操作时不需要分布式锁最坏情况是后到的操作在状态校验处失败
* PC 端换取会话时直接复用 {@link TokenManager#generateToken(Authentication)}
* 生成的令牌与账号密码登录走的是同一套会话治理/多设备登出刷新前端无差别处理
* 扫码登录服务
*
* @author Ray.Hao
* @since 4.5.0
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class QrCodeLoginServiceImpl implements QrCodeLoginService {
public class QrCodeLoginService {
/** 票据默认有效期(秒),二维码在此时间内未走完流程即由 Redis TTL 自动清理 */
/** 票据默认有效期(秒) */
private static final int DEFAULT_EXPIRE_SECONDS = 300;
/** 状态流转时若 Redis 剩余 TTL 小于该值,补足到此值,避免临界点票据在下一步操作前被清掉 */
/** 状态流转时若 Redis 剩余 TTL 小于该值,补足到此值 */
private static final int MIN_REMAIN_SECONDS = 30;
/**
@@ -56,8 +51,10 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
private final TokenManager tokenManager;
private final UserSocialService userSocialService;
@Override
public QrCodeGenerateVO generate(String clientIp) {
/**
* 生成扫码票据写入 Redis 后返回 ticket 和有效期
*/
public QrCodeGenerateResp generate(String clientIp) {
String ticket = IdUtil.fastSimpleUUID();
QrCodeLoginContext ctx = new QrCodeLoginContext();
ctx.setTicket(ticket);
@@ -65,60 +62,70 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
ctx.setCreatedAt(System.currentTimeMillis());
ctx.setClientIp(clientIp);
save(ctx, DEFAULT_EXPIRE_SECONDS);
return QrCodeGenerateVO.builder()
return QrCodeGenerateResp.builder()
.ticket(ticket)
.expireSeconds(DEFAULT_EXPIRE_SECONDS)
.build();
}
@Override
public QrCodeStatusVO status(String ticket) {
/**
* PC 端轮询扫码状态未登录调用
*/
public QrCodeStatusResp status(String ticket) {
QrCodeLoginContext ctx = loadOrThrow(ticket);
return toVO(ctx, remainingSeconds(ticket));
return toResp(ctx, remainingSeconds(ticket));
}
@Override
public QrCodeStatusVO scan(String ticket, Long userId) {
/**
* APP 端标记已扫码需已登录WAITING SCANNED
*/
public QrCodeStatusResp scan(String ticket, Long userId) {
QrCodeLoginContext ctx = loadOrThrow(ticket);
requireStatus(ctx, QrCodeLoginStatusEnum.WAITING);
fillUserInfo(ctx, userId);
ctx.setStatus(QrCodeLoginStatusEnum.SCANNED.name());
ctx.setScannedAt(System.currentTimeMillis());
save(ctx, refreshTtl(ticket));
return toVO(ctx, remainingSeconds(ticket));
return toResp(ctx, remainingSeconds(ticket));
}
@Override
public QrCodeStatusVO confirm(String ticket, Long userId) {
/**
* APP 端确认登录需扫码本人操作SCANNED CONFIRMED
*/
public QrCodeStatusResp confirm(String ticket, Long userId) {
QrCodeLoginContext ctx = loadOrThrow(ticket);
requireStatus(ctx, QrCodeLoginStatusEnum.SCANNED);
requireSameUser(ctx, userId);
ctx.setStatus(QrCodeLoginStatusEnum.CONFIRMED.name());
ctx.setConfirmedAt(System.currentTimeMillis());
save(ctx, refreshTtl(ticket));
return toVO(ctx, remainingSeconds(ticket));
return toResp(ctx, remainingSeconds(ticket));
}
@Override
public QrCodeStatusVO cancel(String ticket, Long userId) {
/**
* APP 端取消登录仅流程中的票据可取消
*/
public QrCodeStatusResp cancel(String ticket, Long userId) {
QrCodeLoginContext ctx = loadOrThrow(ticket);
QrCodeLoginStatusEnum current = QrCodeLoginStatusEnum.valueOf(ctx.getStatus());
// 只有还在流程中的票据等待扫码 / 已扫码 / 已确认允许取消已登录或已取消的重复操作直接拒绝
// 只有还在流程中的票据等待扫码 / 已扫码 / 已确认允许取消
if (current != QrCodeLoginStatusEnum.WAITING
&& current != QrCodeLoginStatusEnum.SCANNED
&& current != QrCodeLoginStatusEnum.CONFIRMED) {
throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL);
}
// 一旦有人扫过码取消权就归扫码本人防止他人替扫码用户取消
// 一旦有人扫过码取消权就归扫码本人
if (current != QrCodeLoginStatusEnum.WAITING && ctx.getUserId() != null) {
requireSameUser(ctx, userId);
}
ctx.setStatus(QrCodeLoginStatusEnum.CANCELED.name());
save(ctx, refreshTtl(ticket));
return toVO(ctx, remainingSeconds(ticket));
return toResp(ctx, remainingSeconds(ticket));
}
@Override
/**
* PC 端用票据换取会话令牌CONFIRMED LOGGED_IN一次性
*/
public AuthenticationToken login(String ticket) {
QrCodeLoginContext ctx = loadOrThrow(ticket);
requireStatus(ctx, QrCodeLoginStatusEnum.CONFIRMED);
@@ -130,15 +137,15 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
Authentication authentication = new UsernamePasswordAuthenticationToken(
details, null, details.getAuthorities());
AuthenticationToken token = tokenManager.generateToken(authentication);
// 换取令牌成功后立即把票据置为已使用一次性再次 login 会在 requireStatus(CONFIRMED) 处被拒杜绝重放
// 换取令牌成功后立即把票据置为已使用一次性再次 login 会在 requireStatus(CONFIRMED) 处被拒
ctx.setStatus(QrCodeLoginStatusEnum.LOGGED_IN.name());
save(ctx, Math.max(remainingSeconds(ticket), MIN_REMAIN_SECONDS));
return token;
}
// ======================== private ========================
/** 读取票据上下文,票据为空、不存在或已过期都视为 QR_CODE_NOT_FOUND */
/**
* 读取票据上下文票据为空不存在或已过期都视为 QR_CODE_NOT_FOUND
*/
private QrCodeLoginContext loadOrThrow(String ticket) {
if (StrUtil.isBlank(ticket)) {
throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND);
@@ -159,21 +166,27 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
}
}
/** 当前状态必须等于期望状态,否则说明流程被跳步或重复操作 */
/**
* 当前状态必须等于期望状态否则说明流程被跳步或重复操作
*/
private void requireStatus(QrCodeLoginContext ctx, QrCodeLoginStatusEnum expected) {
if (!expected.name().equals(ctx.getStatus())) {
throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL);
}
}
/** 操作者必须是当初扫码的那个用户,防止 A 扫码 B 确认 */
/**
* 操作者必须是当初扫码的那个用户防止 A 扫码 B 确认
*/
private void requireSameUser(QrCodeLoginContext ctx, Long userId) {
if (ctx.getUserId() == null || !ctx.getUserId().equals(userId)) {
throw new TokenInvalidException(ResultCode.QR_CODE_USER_MISMATCH);
}
}
/** 扫码时把当前 APP 用户的昵称、头像写进上下文,供 PC 端 status 展示 */
/**
* 扫码时把当前 APP 用户的昵称头像写进上下文 PC status 展示
*/
private void fillUserInfo(QrCodeLoginContext ctx, Long userId) {
SecurityUser info = userSocialService.getAuthInfoByUserId(userId);
if (info == null) {
@@ -184,35 +197,43 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
ctx.setAvatar(info.getAvatar());
}
/** 重写整个上下文并刷新 TTL */
/**
* 重写整个上下文并刷新 TTL
*/
private void save(QrCodeLoginContext ctx, int ttl) {
redisTemplate.opsForValue().set(key(ctx.getTicket()), ctx, ttl, TimeUnit.SECONDS);
}
/** 票据在 Redis 中的剩余秒数,取不到或已过期返回 0 */
/**
* 票据在 Redis 中的剩余秒数取不到或已过期返回 0
*/
private int remainingSeconds(String ticket) {
Long ttl = redisTemplate.getExpire(key(ticket), TimeUnit.SECONDS);
return ttl == null ? 0 : Math.max(ttl.intValue(), 0);
}
/** 状态流转时写回的 TTL维持剩余时间不足 MIN_REMAIN_SECONDS 则补足 */
/**
* 状态流转时写回的 TTL维持剩余时间不足 MIN_REMAIN_SECONDS 则补足
*/
private int refreshTtl(String ticket) {
int remain = remainingSeconds(ticket);
return remain < MIN_REMAIN_SECONDS ? MIN_REMAIN_SECONDS : remain;
}
/** 上下文转前端 VO昵称脱敏、用户信息仅在扫码后暴露 */
private QrCodeStatusVO toVO(QrCodeLoginContext ctx, int expireSeconds) {
/**
* 上下文转前端 VO昵称脱敏用户信息仅在扫码后暴露
*/
private QrCodeStatusResp toResp(QrCodeLoginContext ctx, int expireSeconds) {
QrCodeLoginStatusEnum status = QrCodeLoginStatusEnum.valueOf(ctx.getStatus());
String nickname = null;
String avatar = null;
// WAITING 阶段谁都能查状态此时不能泄露用户信息扫码/确认后才回传脱敏昵称与头像
if (status == QrCodeLoginStatusEnum.SCANNED
|| status == QrCodeLoginStatusEnum.CONFIRMED) {
nickname = QrCodeNicknameMasker.mask(ctx.getNickname());
nickname = maskNickname(ctx.getNickname());
avatar = ctx.getAvatar();
}
return QrCodeStatusVO.builder()
return QrCodeStatusResp.builder()
.ticket(ctx.getTicket())
.status(status.name())
.nickname(nickname)
@@ -221,7 +242,23 @@ public class QrCodeLoginServiceImpl implements QrCodeLoginService {
.build();
}
/** 拼接票据在 Redis 中的完整 Key */
/**
* 昵称脱敏保留首尾各一个字符中间用 * 替换
*/
private String maskNickname(String nickname) {
if (StrUtil.isBlank(nickname)) {
return "";
}
int len = nickname.length();
if (len == 1) {
return nickname;
}
return StrUtil.hide(nickname, 1, len - 1);
}
/**
* 拼接票据在 Redis 中的完整 Key
*/
private String key(String ticket) {
return StrUtil.format(RedisConstants.Auth.QR_CODE_LOGIN, ticket);
}

View File

@@ -1,15 +1,53 @@
package com.youlai.boot.auth.service;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.model.resp.WxMaLoginResp;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.common.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.token.WxMaAuthenticationToken;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.system.model.entity.SysUser;
import com.youlai.boot.system.service.UserSocialService;
import com.youlai.boot.system.service.UserService;
import com.youlai.boot.system.service.UserRoleService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Collections;
/**
* 微信小程序认证服务接口
* 微信小程序认证服务
*
* @author Ray.Hao
* @since 2.4.0
* @since 4.0.0
*/
public interface WxMaAuthService {
@Service
@RequiredArgsConstructor
@Slf4j
public class WxMaAuthService {
private final WxMaService wxMaService;
private final AuthenticationManager authenticationManager;
private final TokenManager tokenManager;
private final UserService userService;
private final UserSocialService userSocialService;
private final UserRoleService userRoleService;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 静默登录
@@ -21,21 +59,61 @@ public interface WxMaAuthService {
* @param code 微信登录凭证wx.login 获取)
* @return 登录结果(成功返回 token需绑定返回 openid
*/
WxMaLoginVO silentLogin(String code);
public WxMaLoginResp silentLogin(String code) {
WxMaAuthenticationToken token = new WxMaAuthenticationToken(code);
try {
Authentication authentication = authenticationManager.authenticate(token);
AuthenticationToken authToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return WxMaLoginResp.builder()
.isNewUser(false)
.needBindMobile(false)
.accessToken(authToken.getAccessToken())
.refreshToken(authToken.getRefreshToken())
.tokenType(authToken.getTokenType())
.expiresIn(authToken.getExpiresIn())
.build();
} catch (MobileNotBoundException e) {
return WxMaLoginResp.builder()
.isNewUser(true)
.needBindMobile(true)
.openid(e.getOpenid())
.build();
}
}
/**
* 手机号快捷登录
* <p>
* 同时使用微信登录凭证和手机号授权凭证,
* 一步完成用户注册/登录,无需额外绑定流程。
* 适用于企业认证的小程序(已开通手机号快捷登录权限)。
* </p>
*
* @param loginCode 微信登录凭证wx.login 获取)
* @param phoneCode 手机号授权凭证getPhoneNumber 事件获取)
* @return 认证令牌
*/
AuthenticationToken phoneLogin(String loginCode, String phoneCode);
@Transactional(rollbackFor = Exception.class)
public AuthenticationToken phoneLogin(String loginCode, String phoneCode) {
// 1. 解析微信登录凭证,获取会话信息
WxMaJscode2SessionResult session = resolveSession(loginCode);
String openid = session.getOpenid();
// 2. 解析手机号授权凭证,获取手机号
String mobile = resolvePhoneNumber(phoneCode);
log.info("微信小程序手机号快捷登录openid={}, mobile={}", openid, mobile);
// 3. 查询或创建用户
SysUser user = findOrCreateUser(mobile);
// 4. 绑定微信 openid
bindWechatOpenid(user, session);
// 5. 生成认证令牌
return generateAuthToken(mobile);
}
/**
* 绑定手机号
@@ -49,5 +127,139 @@ public interface WxMaAuthService {
* @param smsCode 短信验证码
* @return 认证令牌
*/
AuthenticationToken bindMobile(String openid, String mobile, String smsCode);
@Transactional(rollbackFor = Exception.class)
public AuthenticationToken bindMobile(String openid, String mobile, String smsCode) {
// 1. 验证短信验证码
validateSmsCode(mobile, smsCode);
// 2. 查询或创建用户
SysUser user = findOrCreateUser(mobile);
// 3. 绑定微信 openid
userSocialService.bindOrUpdate(
user.getId(),
SocialPlatformEnum.WECHAT_MINI,
openid,
null, null, null, null
);
log.info("微信小程序绑定手机号成功mobile={}, openid={}", mobile, openid);
// 4. 生成认证令牌
return generateAuthToken(mobile);
}
// ==================== 私有方法 ====================
/**
* 解析微信登录凭证,获取会话信息
*/
private WxMaJscode2SessionResult resolveSession(String loginCode) {
try {
return wxMaService.jsCode2SessionInfo(loginCode);
} catch (Exception e) {
log.error("获取微信会话信息失败loginCode={}", loginCode, e);
throw new IllegalArgumentException("微信登录失败:" + e.getMessage());
}
}
/**
* 解析手机号授权凭证,获取手机号
*/
private String resolvePhoneNumber(String phoneCode) {
try {
WxMaPhoneNumberInfo phoneInfo = wxMaService.getUserService().getPhoneNoInfo(phoneCode);
return phoneInfo.getPhoneNumber();
} catch (Exception e) {
log.error("获取微信手机号失败phoneCode={}", phoneCode, e);
throw new IllegalArgumentException("获取手机号失败:" + e.getMessage());
}
}
/**
* 查询或创建用户
*/
private SysUser findOrCreateUser(String mobile) {
SysUser user = userService.lambdaQuery()
.eq(SysUser::getMobile, mobile)
.one();
if (user == null) {
user = createNewUser(mobile);
log.info("微信小程序登录创建新用户mobile={}, userId={}", mobile, user.getId());
}
return user;
}
/**
* 创建新用户
* <p>
* 新用户默认分配 GUEST访问游客角色
* </p>
*/
private SysUser createNewUser(String mobile) {
SysUser user = new SysUser();
user.setMobile(mobile);
user.setUsername("wx_" + IdUtil.fastSimpleUUID().substring(0, 8));
user.setNickname("微信用户");
user.setStatus(1);
user.setIsDeleted(0);
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
userService.save(user);
// 分配 GUEST 角色角色ID=3
userRoleService.saveUserRoles(user.getId(), Collections.singletonList(3L));
return user;
}
/**
* 绑定微信 openid
*/
private void bindWechatOpenid(SysUser user, WxMaJscode2SessionResult session) {
try {
userSocialService.bindOrUpdate(
user.getId(),
SocialPlatformEnum.WECHAT_MINI,
session.getOpenid(),
session.getUnionid(),
user.getNickname(),
user.getAvatar(),
session.getSessionKey()
);
} catch (Exception e) {
// 绑定失败不影响登录
log.warn("绑定微信 openid 失败userId={}, openid={}", user.getId(), session.getOpenid(), e);
}
}
/**
* 验证短信验证码
*/
private void validateSmsCode(String mobile, String smsCode) {
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile);
String cachedCode = (String) redisTemplate.opsForValue().get(cacheKey);
if (!StrUtil.equals(smsCode, cachedCode)) {
throw new IllegalArgumentException("验证码错误");
}
// 验证成功后删除验证码
redisTemplate.delete(cacheKey);
}
/**
* 生成认证令牌
*/
private AuthenticationToken generateAuthToken(String mobile) {
SecurityUserDetails userDetails = new SecurityUserDetails(userService.getAuthInfoByMobile(mobile));
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
AuthenticationToken authToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authToken;
}
}

View File

@@ -1,151 +0,0 @@
package com.youlai.boot.auth.service.impl;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.service.AuthService;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.captcha.model.CaptchaInfo;
import com.youlai.boot.framework.captcha.service.CaptchaService;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.auth.security.model.SmsAuthenticationToken;
import com.youlai.boot.framework.security.util.SecurityUtils;
import com.youlai.boot.framework.integration.sms.enums.SmsTypeEnum;
import com.youlai.boot.framework.integration.sms.service.SmsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
/**
* 认证服务实现类
*
* @author Ray.Hao
* @since 2.4.0
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthServiceImpl implements AuthService {
private final AuthenticationManager authenticationManager;
private final TokenManager tokenManager;
private final SmsService smsService;
private final RedisTemplate<String, Object> redisTemplate;
private final CaptchaService captchaService;
/**
* 用户名密码登录
*
* @param username 用户名
* @param password 密码
* @return 访问令牌
*/
@Override
public AuthenticationToken login(String username, String password) {
// 1. 创建用于密码认证的令牌(未认证)
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username.trim(), password);
// 2. 执行认证(认证中)
// 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。
// 默认情况下会委托给 DaoAuthenticationProvider
// 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SecurityUserDetailsService 实现)
// 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配)
// 认证通过后返回已认证的 Authenticationprincipal 为 SecurityUserDetailsauthorities 为角色/权限集合)。
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
AuthenticationToken authenticationTokenResponse =
tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authenticationTokenResponse;
}
/**
* 发送登录短信验证码
*
* @param mobile 手机号
*/
@Override
public void sendSmsCode(String mobile) {
// 随机生成4位验证码
// String code = String.valueOf((int) ((Math.random() * 9 + 1) * 1000));
// TODO 为了方便测试,验证码固定为 1234实际开发中在配置了厂商短信服务后可以使用上面的随机验证码
String code = "1234";
// 发送短信验证码
Map<String, String> templateParams = new HashMap<>();
templateParams.put("code", code);
try {
smsService.sendSms(mobile, SmsTypeEnum.LOGIN, templateParams);
} catch (Exception e) {
log.error("发送短信验证码失败", e);
}
// 缓存验证码至Redis用于登录校验
redisTemplate.opsForValue().set(StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile), code, 5, TimeUnit.MINUTES);
}
/**
* 短信验证码登录
*
* @param mobile 手机号
* @param code 验证码
* @return 访问令牌
*/
@Override
public AuthenticationToken loginBySms(String mobile, String code) {
// 1. 创建用户短信验证码认证的令牌(未认证)
SmsAuthenticationToken smsAuthenticationToken = new SmsAuthenticationToken(mobile, code);
// 2. 执行认证(认证中)
Authentication authentication = authenticationManager.authenticate(smsAuthenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
AuthenticationToken authenticationToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authenticationToken;
}
/**
* 注销登录
*/
@Override
public void logout() {
String token = SecurityUtils.getAccessToken();
if (StrUtil.isNotBlank(token)) {
tokenManager.invalidateToken(token);
// 清除Security上下文
SecurityContextHolder.clearContext();
}
}
/**
* 获取验证码
*/
@Override
public CaptchaInfo getCaptcha() {
return captchaService.generate();
}
/**
* 刷新token
*
* @param refreshToken 刷新令牌
* @return 新的访问令牌
*/
@Override
public AuthenticationToken refreshToken(String refreshToken) {
return tokenManager.refreshToken(refreshToken);
}
}

View File

@@ -1,245 +0,0 @@
package com.youlai.boot.auth.service.impl;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.model.vo.WxMaLoginVO;
import com.youlai.boot.auth.service.WxMaAuthService;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.framework.security.model.AuthenticationToken;
import com.youlai.boot.framework.security.model.SecurityUserDetails;
import com.youlai.boot.framework.security.token.TokenManager;
import com.youlai.boot.auth.security.exception.MobileNotBoundException;
import com.youlai.boot.auth.security.model.WxMaAuthenticationToken;
import com.youlai.boot.common.enums.SocialPlatformEnum;
import com.youlai.boot.system.model.entity.SysUser;
import com.youlai.boot.system.service.UserSocialService;
import com.youlai.boot.system.service.UserService;
import com.youlai.boot.system.service.UserRoleService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Collections;
/**
* 微信小程序认证服务实现
*
* @author Ray.Hao
* @since 4.0.0
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class WxMaAuthServiceImpl implements WxMaAuthService {
private final WxMaService wxMaService;
private final AuthenticationManager authenticationManager;
private final TokenManager tokenManager;
private final UserService userService;
private final UserSocialService userSocialService;
private final UserRoleService userRoleService;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 静默登录
*/
@Override
public WxMaLoginVO silentLogin(String code) {
WxMaAuthenticationToken token = new WxMaAuthenticationToken(code);
try {
Authentication authentication = authenticationManager.authenticate(token);
AuthenticationToken authToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return WxMaLoginVO.builder()
.isNewUser(false)
.needBindMobile(false)
.accessToken(authToken.getAccessToken())
.refreshToken(authToken.getRefreshToken())
.tokenType(authToken.getTokenType())
.expiresIn(authToken.getExpiresIn())
.build();
} catch (MobileNotBoundException e) {
return WxMaLoginVO.builder()
.isNewUser(true)
.needBindMobile(true)
.openid(e.getOpenid())
.build();
}
}
/**
* 手机号快捷登录
*/
@Override
@Transactional(rollbackFor = Exception.class)
public AuthenticationToken phoneLogin(String loginCode, String phoneCode) {
// 1. 解析微信登录凭证,获取会话信息
WxMaJscode2SessionResult session = resolveSession(loginCode);
String openid = session.getOpenid();
// 2. 解析手机号授权凭证,获取手机号
String mobile = resolvePhoneNumber(phoneCode);
log.info("微信小程序手机号快捷登录openid={}, mobile={}", openid, mobile);
// 3. 查询或创建用户
SysUser user = findOrCreateUser(mobile);
// 4. 绑定微信 openid
bindWechatOpenid(user, session);
// 5. 生成认证令牌
return generateAuthToken(mobile);
}
/**
* 绑定手机号
*/
@Override
@Transactional(rollbackFor = Exception.class)
public AuthenticationToken bindMobile(String openid, String mobile, String smsCode) {
// 1. 验证短信验证码
validateSmsCode(mobile, smsCode);
// 2. 查询或创建用户
SysUser user = findOrCreateUser(mobile);
// 3. 绑定微信 openid
userSocialService.bindOrUpdate(
user.getId(),
SocialPlatformEnum.WECHAT_MINI,
openid,
null, null, null, null
);
log.info("微信小程序绑定手机号成功mobile={}, openid={}", mobile, openid);
// 4. 生成认证令牌
return generateAuthToken(mobile);
}
// ==================== 私有方法 ====================
/**
* 解析微信登录凭证,获取会话信息
*/
private WxMaJscode2SessionResult resolveSession(String loginCode) {
try {
return wxMaService.jsCode2SessionInfo(loginCode);
} catch (Exception e) {
log.error("获取微信会话信息失败loginCode={}", loginCode, e);
throw new IllegalArgumentException("微信登录失败:" + e.getMessage());
}
}
/**
* 解析手机号授权凭证,获取手机号
*/
private String resolvePhoneNumber(String phoneCode) {
try {
WxMaPhoneNumberInfo phoneInfo = wxMaService.getUserService().getPhoneNoInfo(phoneCode);
return phoneInfo.getPhoneNumber();
} catch (Exception e) {
log.error("获取微信手机号失败phoneCode={}", phoneCode, e);
throw new IllegalArgumentException("获取手机号失败:" + e.getMessage());
}
}
/**
* 查询或创建用户
*/
private SysUser findOrCreateUser(String mobile) {
SysUser user = userService.lambdaQuery()
.eq(SysUser::getMobile, mobile)
.one();
if (user == null) {
user = createNewUser(mobile);
log.info("微信小程序登录创建新用户mobile={}, userId={}", mobile, user.getId());
}
return user;
}
/**
* 创建新用户
* <p>
* 新用户默认分配 GUEST访问游客角色
* </p>
*/
private SysUser createNewUser(String mobile) {
SysUser user = new SysUser();
user.setMobile(mobile);
user.setUsername("wx_" + IdUtil.fastSimpleUUID().substring(0, 8));
user.setNickname("微信用户");
user.setStatus(1);
user.setIsDeleted(0);
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
userService.save(user);
// 分配 GUEST 角色角色ID=3
userRoleService.saveUserRoles(user.getId(), Collections.singletonList(3L));
return user;
}
/**
* 绑定微信 openid
*/
private void bindWechatOpenid(SysUser user, WxMaJscode2SessionResult session) {
try {
userSocialService.bindOrUpdate(
user.getId(),
SocialPlatformEnum.WECHAT_MINI,
session.getOpenid(),
session.getUnionid(),
user.getNickname(),
user.getAvatar(),
session.getSessionKey()
);
} catch (Exception e) {
// 绑定失败不影响登录
log.warn("绑定微信 openid 失败userId={}, openid={}", user.getId(), session.getOpenid(), e);
}
}
/**
* 验证短信验证码
*/
private void validateSmsCode(String mobile, String smsCode) {
String cacheKey = StrUtil.format(RedisConstants.Captcha.SMS_LOGIN_CODE, mobile);
String cachedCode = (String) redisTemplate.opsForValue().get(cacheKey);
if (!StrUtil.equals(smsCode, cachedCode)) {
throw new IllegalArgumentException("验证码错误");
}
// 验证成功后删除验证码
redisTemplate.delete(cacheKey);
}
/**
* 生成认证令牌
*/
private AuthenticationToken generateAuthToken(String mobile) {
SecurityUserDetails userDetails = new SecurityUserDetails(userService.getAuthInfoByMobile(mobile));
Authentication authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
AuthenticationToken authToken = tokenManager.generateToken(authentication);
SecurityContextHolder.getContext().setAuthentication(authentication);
return authToken;
}
}