refactor: 项目包结构优化
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.youlai.boot.auth.enums;
|
||||
|
||||
/**
|
||||
* EasyCaptcha 验证码类型
|
||||
*
|
||||
* @author haoxr
|
||||
* @since 2.5.1
|
||||
*/
|
||||
public enum CaptchaTypeEnum {
|
||||
CIRCLE,
|
||||
GIF,
|
||||
LINE,
|
||||
SHEAR
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.youlai.boot.auth.security.exception;
|
||||
package com.youlai.boot.auth.exception;
|
||||
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/** 扫码用户 ID,scan 时写入 */
|
||||
@@ -34,6 +35,6 @@ public class QrCodeLoginContext {
|
||||
/** 确认时间戳(毫秒) */
|
||||
private Long confirmedAt;
|
||||
|
||||
/** generate 时的 PC 端 IP,用于审计 */
|
||||
/** generate 时的 PC 端 IP */
|
||||
private String clientIp;
|
||||
}
|
||||
@@ -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 = "用户名不能为空")
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 完成匹配)
|
||||
// 认证通过后返回已认证的 Authentication(principal 为 SecurityUserDetails,authorities 为角色/权限集合)。
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
105
src/main/java/com/youlai/boot/auth/service/CaptchaService.java
Normal file
105
src/main/java/com/youlai/boot/auth/service/CaptchaService.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 形式存入 Redis,TTL 与有效期一致;状态流转的合法性全部由
|
||||
* {@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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 完成匹配)
|
||||
// 认证通过后返回已认证的 Authentication(principal 为 SecurityUserDetails,authorities 为角色/权限集合)。
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user