feat(auth): 实现基于阿里云短信验证码的登录注册功能
- 新增阿里云短信发送客户端配置及属性绑定类 - 集成阿里云短信服务实现验证码发送功能 - 基于 Sa-Token 完成登录状态管理和 token 生成 - 实现手机号验证码登录、密码登录及验证码注册支持 - 添加密码加密 Bean,使用 BCrypt 保障密码安全 - 新增 Redis 缓存验证码,实现验证码有效期和校验 - Vue 前端新增登录弹窗组件,支持三种登录模式切换 - 统一 Axios 请求添加 Token 请求头及响应错误提示 - 更新配置文件,加入 Sa-Token 相关配置项 - 调整后端数据库实体生成配置,新增用户表映射 - 添加前端依赖包 @vueuse/integrations 和 universal-cookie - 新增前端 Cookie 操作逻辑,用于 Token 的存取管理 - 优化 Header 组件,增加 Login 按钮触发登录弹窗
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
package com.yinlihupo.enlish.service.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@ConfigurationProperties(prefix = "aliyun")
|
||||
@Component
|
||||
@Data
|
||||
public class AliyunAccessKeyProperties {
|
||||
private String accessKeyId;
|
||||
private String accessKeySecret;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.yinlihupo.enlish.service.config;
|
||||
|
||||
import com.aliyun.dysmsapi20170525.Client;
|
||||
import com.aliyun.teaopenapi.models.Config;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class AliyunSmsClientConfig {
|
||||
|
||||
@Resource
|
||||
private AliyunAccessKeyProperties aliyunAccessKeyProperties;
|
||||
|
||||
@Bean
|
||||
public Client smsClient() {
|
||||
try {
|
||||
Config config = new Config()
|
||||
// 必填
|
||||
.setAccessKeyId(aliyunAccessKeyProperties.getAccessKeyId())
|
||||
// 必填
|
||||
.setAccessKeySecret(aliyunAccessKeyProperties.getAccessKeySecret());
|
||||
|
||||
// Endpoint 请参考 https://api.aliyun.com/product/Dysmsapi
|
||||
config.endpoint = "dysmsapi.aliyuncs.com";
|
||||
|
||||
return new Client(config);
|
||||
} catch (Exception e) {
|
||||
log.error("初始化阿里云短信发送客户端错误: ", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yinlihupo.enlish.service.config;
|
||||
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
|
||||
@Component
|
||||
public class PasswordEncoderConfig {
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
// BCrypt 是一种安全且适合密码存储的哈希算法,它在进行哈希时会自动加入“盐”,增加密码的安全性。
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yinlihupo.enlish.service.constant;
|
||||
|
||||
public class UserRedisConstants {
|
||||
|
||||
public static final String USER_LOGIN_CODE = "user:login:code:";
|
||||
|
||||
public static String buildUserLoginCode(String phone) {
|
||||
return USER_LOGIN_CODE + phone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.yinlihupo.enlish.service.controller;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.yinlihupo.enlish.service.model.vo.login.LoginReqVO;
|
||||
import com.yinlihupo.enlish.service.model.vo.login.VerificationCodeReqVO;
|
||||
import com.yinlihupo.enlish.service.service.LoginService;
|
||||
import com.yinlihupo.framework.biz.operationlog.aspect.ApiOperationLog;
|
||||
import com.yinlihupo.framework.common.response.Response;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.RestController;
|
||||
|
||||
@RequestMapping("/login/")
|
||||
@RestController
|
||||
@Slf4j
|
||||
public class LoginController {
|
||||
|
||||
@Resource
|
||||
private LoginService loginService;
|
||||
|
||||
@PostMapping("login")
|
||||
@ApiOperationLog(description = "登录")
|
||||
public Response<String> login(@RequestBody LoginReqVO loginReqVO) {
|
||||
try {
|
||||
loginService.login(loginReqVO.getPhone(), loginReqVO.getName(), loginReqVO.getPassword(), loginReqVO.getCode());
|
||||
return Response.success(StpUtil.getTokenInfo().getTokenValue());
|
||||
} catch (Exception e) {
|
||||
log.error("注册或登录失败 {}", e.getMessage());
|
||||
return Response.fail("注册或登录失败 " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("sendVerificationCode")
|
||||
@ApiOperationLog(description = "发送验证码")
|
||||
public Response<Void> sendVerificationCode(@RequestBody VerificationCodeReqVO verificationCodeReqVO) {
|
||||
try {
|
||||
loginService.sendVerificationCode(verificationCodeReqVO.getPhone());
|
||||
return Response.success();
|
||||
} catch (Exception e) {
|
||||
log.error("发送验证码失败 {}", e.getMessage());
|
||||
return Response.fail("发送验证码失败 " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yinlihupo.enlish.service.domain.dataobject;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Builder
|
||||
public class UserDO {
|
||||
private Long id;
|
||||
|
||||
private String password;
|
||||
|
||||
private String name;
|
||||
|
||||
private String openid;
|
||||
|
||||
private String phone;
|
||||
|
||||
private String email;
|
||||
|
||||
private Integer status;
|
||||
|
||||
private Integer isDeleted;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yinlihupo.enlish.service.domain.mapper;
|
||||
|
||||
import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
|
||||
|
||||
public interface UserDOMapper {
|
||||
|
||||
UserDO selectByPhone(String phone);
|
||||
|
||||
void insert(UserDO userDO);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yinlihupo.enlish.service.model.vo.login;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Builder
|
||||
public class LoginReqVO {
|
||||
|
||||
private String phone;
|
||||
private String name;
|
||||
private String password;
|
||||
private String code;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.yinlihupo.enlish.service.model.vo.login;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Builder
|
||||
public class VerificationCodeReqVO {
|
||||
|
||||
private String phone;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.yinlihupo.enlish.service.service;
|
||||
|
||||
public interface LoginService {
|
||||
|
||||
void login(String phone, String name, String reqPassword, String reqCode);
|
||||
|
||||
void sendVerificationCode(String phone);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.yinlihupo.enlish.service.service.login;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import com.aliyun.dysmsapi20170525.Client;
|
||||
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
|
||||
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
|
||||
import com.aliyun.teautil.models.RuntimeOptions;
|
||||
import com.yinlihupo.enlish.service.constant.UserRedisConstants;
|
||||
import com.yinlihupo.enlish.service.domain.dataobject.UserDO;
|
||||
import com.yinlihupo.enlish.service.domain.mapper.UserDOMapper;
|
||||
import com.yinlihupo.enlish.service.service.LoginService;
|
||||
import com.yinlihupo.framework.common.util.JsonUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class LoginServiceImpl implements LoginService {
|
||||
|
||||
@Resource
|
||||
private UserDOMapper userDOMapper;
|
||||
@Resource
|
||||
private PasswordEncoder passwordEncoder;
|
||||
@Resource
|
||||
private StringRedisTemplate stringRedisTemplate;
|
||||
@Resource
|
||||
private Client client;
|
||||
|
||||
@Override
|
||||
public void login(String phone, String name, String reqPassword, String reqCode) {
|
||||
UserDO userDO = userDOMapper.selectByPhone(phone);
|
||||
log.info("userDO:{}", userDO);
|
||||
|
||||
String code = stringRedisTemplate.opsForValue().get(UserRedisConstants.buildUserLoginCode(phone));
|
||||
if (userDO == null) {
|
||||
if (code == null || !code.equals(reqCode)) {
|
||||
throw new RuntimeException("验证码错误");
|
||||
}
|
||||
userDO = UserDO.builder()
|
||||
.phone(phone)
|
||||
.name(name)
|
||||
.password(passwordEncoder.encode(reqPassword))
|
||||
.build();
|
||||
userDOMapper.insert(userDO);
|
||||
StpUtil.login(userDO.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
if (code != null && code.equals(reqCode)) {
|
||||
StpUtil.login(userDO.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
if (reqPassword != null && passwordEncoder.matches(reqPassword, userDO.getPassword())) {
|
||||
StpUtil.login(userDO.getId());
|
||||
throw new RuntimeException("密码错误");
|
||||
|
||||
}
|
||||
|
||||
throw new RuntimeException("登录错误");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendVerificationCode(String phone) {
|
||||
String code = RandomUtil.randomNumbers(6);
|
||||
stringRedisTemplate.opsForValue().set(UserRedisConstants.buildUserLoginCode(phone), code, Duration.ofSeconds(60));
|
||||
String signName = "短信测试";
|
||||
String templateCode = "SMS_154950909";
|
||||
String templateParam = String.format("{\"code\":\"%s\"}", code);
|
||||
try {
|
||||
sendMessage(phone, signName, templateCode, templateParam);
|
||||
} catch (Exception e) {
|
||||
log.error("==> 短信发送失败, phone: {}, signName: {}, templateCode: {}, templateParam: {}", phone, signName, templateCode, templateParam);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void initUserRole(UserDO userDO) {
|
||||
// todo
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信
|
||||
*/
|
||||
public void sendMessage(String signName, String templateCode, String phone, String templateParam) throws Exception {
|
||||
SendSmsRequest sendSmsRequest = new SendSmsRequest()
|
||||
.setSignName(signName)
|
||||
.setTemplateCode(templateCode)
|
||||
.setPhoneNumbers(phone)
|
||||
.setTemplateParam(templateParam);
|
||||
RuntimeOptions runtime = new RuntimeOptions();
|
||||
|
||||
|
||||
log.info("==> 开始短信发送, phone: {}, signName: {}, templateCode: {}, templateParam: {}", phone, signName, templateCode, templateParam);
|
||||
|
||||
// 发送短信
|
||||
SendSmsResponse response = client.sendSmsWithOptions(sendSmsRequest, runtime);
|
||||
|
||||
log.info("==> 短信发送成功, response: {}", JsonUtils.toJsonString(response));
|
||||
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,8 @@ tmp:
|
||||
|
||||
ai:
|
||||
key: app-loC6IrJpj4cS54MAYp73QtGl
|
||||
url: https://chat.cosonggle.com/v1/chat-messages
|
||||
url: https://chat.cosonggle.com/v1/chat-messages
|
||||
|
||||
aliyun:
|
||||
accessKeyId:
|
||||
accessKeySecret:
|
||||
@@ -7,4 +7,23 @@ spring:
|
||||
|
||||
mybatis:
|
||||
# MyBatis xml 配置文件路径
|
||||
mapper-locations: classpath:/mapper/**/*.xml
|
||||
mapper-locations: classpath:/mapper/**/*.xml
|
||||
|
||||
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
|
||||
sa-token:
|
||||
# token 名称(同时也是 cookie 名称)
|
||||
token-name: Authorization
|
||||
# token 前缀
|
||||
token-prefix: Bearer
|
||||
# token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
|
||||
token-style: random-128
|
||||
# token 有效期(单位:秒) 默认30天,-1 代表永久有效
|
||||
timeout: 2592000
|
||||
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
|
||||
active-timeout: -1
|
||||
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
|
||||
is-concurrent: true
|
||||
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
|
||||
is-share: true
|
||||
# 是否输出操作日志
|
||||
is-log: true
|
||||
@@ -45,7 +45,7 @@
|
||||
targetProject="src/main/java"/>
|
||||
|
||||
<!-- 需要生成的表-实体类 -->
|
||||
<table tableName="student_lesson_plans" domainObjectName="StudentLessonPlansDO"
|
||||
<table tableName="user" domainObjectName="UserDO"
|
||||
enableCountByExample="false"
|
||||
enableUpdateByExample="false"
|
||||
enableDeleteByExample="false"
|
||||
|
||||
27
enlish-service/src/main/resources/mapper/UserDOMapper.xml
Normal file
27
enlish-service/src/main/resources/mapper/UserDOMapper.xml
Normal file
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.yinlihupo.enlish.service.domain.mapper.UserDOMapper">
|
||||
<resultMap id="BaseResultMap" type="com.yinlihupo.enlish.service.domain.dataobject.UserDO">
|
||||
<id column="id" jdbcType="BIGINT" property="id" />
|
||||
<result column="password" jdbcType="VARCHAR" property="password" />
|
||||
<result column="name" jdbcType="VARCHAR" property="name" />
|
||||
<result column="openid" jdbcType="VARCHAR" property="openid" />
|
||||
<result column="phone" jdbcType="VARCHAR" property="phone" />
|
||||
<result column="email" jdbcType="VARCHAR" property="email" />
|
||||
<result column="status" jdbcType="INTEGER" property="status" />
|
||||
<result column="is_deleted" jdbcType="INTEGER" property="isDeleted" />
|
||||
</resultMap>
|
||||
|
||||
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
|
||||
insert into user (password, phone, name)
|
||||
values (#{password}, #{phone}, #{name})
|
||||
</insert>
|
||||
|
||||
<select id="selectByPhone" resultMap="BaseResultMap">
|
||||
select *
|
||||
from user
|
||||
where phone = #{phone}
|
||||
and is_deleted = 0
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
Reference in New Issue
Block a user