无法运行。增加注册逻辑

This commit is contained in:
2025-08-02 10:04:18 +08:00
parent 293820b557
commit 9832336b89
13 changed files with 621 additions and 19 deletions

33
pom.xml
View File

@@ -54,6 +54,14 @@
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
@@ -76,6 +84,12 @@
<scope>runtime</scope>
<version>8.0.33</version>
</dependency>
<!--添加数据库驱动依赖(根据实际数据库选择)-->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!--异步处理-->
<dependency>
@@ -90,6 +104,25 @@
<artifactId>dysmsapi20170525</artifactId>
<version>3.1.1</version>
</dependency>
<!-- JJWT (最新版) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,14 @@
package com.ttstd.videotablet.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class CommonConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -1,4 +1,4 @@
package com.ttstd.videotablet.redis;
package com.ttstd.videotablet.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

View File

@@ -0,0 +1,40 @@
package com.ttstd.videotablet.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final UserDetailsService userDetailsService;
@Autowired
public SecurityConfig(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
);
return http.build();
}
// 添加AuthenticationManager bean定义
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
}

View File

@@ -14,7 +14,7 @@ public class HelloController {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@GetMapping("/")
@GetMapping("/public/hello")
public Result getMethodName() {
return Result.ok().message("Welcome to Yijiantong");
}
@@ -24,7 +24,7 @@ public class HelloController {
*
* @return
*/
@PostMapping("/set")
@PostMapping("/public/set")
public Result setRedis(@RequestParam(value = "username") String username) {
//存储 key-value 键值对: "username"-"jaychou"
stringRedisTemplate.opsForValue().set("username", username);
@@ -36,7 +36,7 @@ public class HelloController {
*
* @return
*/
@GetMapping("/get")
@GetMapping("/public/get")
public Result getRedis(@RequestParam(value = "username") String username) {
//通过 key 值读取 value
String result = stringRedisTemplate.opsForValue().get(username);

View File

@@ -0,0 +1,150 @@
package com.ttstd.videotablet.controller;
import com.ttstd.videotablet.entity.User;
import com.ttstd.videotablet.service.UserService;
import com.ttstd.videotablet.utils.TextUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import java.util.Map;
import java.util.Objects;
@RestController
public class LoginController {
private final UserService userService;
private final AuthenticationManager authenticationManager;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
public LoginController(UserService userService, AuthenticationManager authenticationManager) {
this.userService = userService;
this.authenticationManager = authenticationManager;
}
@PostMapping("/public/register")
public ResponseEntity<?> registerUser(@RequestBody RegisterRequest registerRequest) {
try {
userService.registerUser(registerRequest.getUsername(), registerRequest.getPassword());
return new ResponseEntity<>("User registered successfully", HttpStatus.CREATED);
} catch (RuntimeException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
}
@PostMapping("/public/login")
public ResponseEntity<?> login() {
// 登录逻辑由Spring Security自动处理
return ResponseEntity.ok("Login successful");
}
// 注册请求参数类
public static class RegisterRequest {
private String username;
private String password;
// Getters and Setters
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
@PostMapping("/public/registerByPhone")
public ResponseEntity<?> registerByPhone(@RequestBody PhoneRequest request) {
String requestVerifyKey = request.getVerifyKey();
if (TextUtils.isEmpty(requestVerifyKey)) {
return new ResponseEntity<>("verify key is empty", HttpStatus.BAD_REQUEST);
}
String phone = request.getPhone();
Map<String, Object> map = (Map<String, Object>) redisTemplate.opsForValue().get(phone);
if (map != null) {
String verifyKey = (String) map.get("verifyKey");
if (!Objects.equals(verifyKey, requestVerifyKey)) {
return new ResponseEntity<>("verify key is not same", HttpStatus.BAD_REQUEST);
}
String code = map.get("code").toString();
if (!Objects.equals(code, request.getCode())) {
return new ResponseEntity<>("code is not same", HttpStatus.BAD_REQUEST);
}
try {
User user = userService.registerByPhone(request.getPhone(), request.getCode(), new Date());
return new ResponseEntity<>(user, HttpStatus.CREATED);
} catch (RuntimeException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
} else {
return new ResponseEntity<>("verify key is expired", HttpStatus.BAD_REQUEST);
}
}
@PostMapping("/public/loginByPhone")
public ResponseEntity<?> loginByPhone(@RequestBody PhoneRequest request) {
String requestVerifyKey = request.getVerifyKey();
if (TextUtils.isEmpty(requestVerifyKey)) {
return new ResponseEntity<>("verify key is empty", HttpStatus.BAD_REQUEST);
}
try {
User user = userService.loginByPhone(request.getPhone(), request.getCode());
// 生成并返回JWT令牌实际项目中需要实现JWT逻辑
return ResponseEntity.ok("Login successful: " + user.getUsername());
} catch (RuntimeException e) {
return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST);
}
}
public static class PhoneRequest {
private String phone;
private String code;
private String verifyKey;
// Getters and Setters
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getVerifyKey() {
return verifyKey;
}
public void setVerifyKey(String verifyKey) {
this.verifyKey = verifyKey;
}
}
}

View File

@@ -16,7 +16,6 @@ import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@RestController
public class SmsController {
@@ -29,21 +28,31 @@ public class SmsController {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@GetMapping("/verify_code")
@GetMapping("/public/verify_code")
public Result getMethodName(@RequestParam("phone") String phone) {
if (TextUtils.isEmpty(phone)) {
return Result.error().message("phone number is empty");
}
String oldCode = stringRedisTemplate.opsForValue().get(phone);
if (TextUtils.isEmpty(oldCode)) {
String code = SendSms.generatedcode(6);
return sendCode(phone, code);
Map<String, Object> map = (Map<String, Object>) redisTemplate.opsForValue().get(phone);
if (map != null) {
String oldCode = (String) map.get("code");
long sentTime = (Long) map.get("sentAt");
if (System.currentTimeMillis() - sentTime < Duration.ofMinutes(1).toMillis()) {
return Result.error().message("code has been sent, please try again after 1 minute");
}
if (TextUtils.isEmpty(oldCode)) {
String code = SendSms.generatedcode(6);
return sendCode(phone, code, false);
} else {
return sendCode(phone, oldCode, true);
}
} else {
return sendCode(phone, oldCode);
String code = SendSms.generatedcode(6);
return sendCode(phone, code, false);
}
}
private Result sendCode(String phone, String code) {
private Result sendCode(String phone, String code, boolean sent) {
try {
com.aliyun.dysmsapi20170525.Client client = createClient();
com.aliyun.dysmsapi20170525.models.SendSmsRequest sendSmsRequest = new com.aliyun.dysmsapi20170525.models.SendSmsRequest()
@@ -54,20 +63,20 @@ public class SmsController {
com.aliyun.teautil.models.RuntimeOptions runtime = new com.aliyun.teautil.models.RuntimeOptions();
// 复制代码运行请自行打印 API 的返回值
SendSmsResponse sendSmsResponse = client.sendSmsWithOptions(sendSmsRequest, runtime);
// System.out.println(sendSmsResponse.body.toMap());
// System.out.println(sendSmsResponse.getBody().getMessage());
// System.out.println(sendSmsResponse.getBody().getCode());
if ("OK".equals(sendSmsResponse.getBody().getCode())) {
String randomString = RandomStringUtils.randomAlphanumeric(32);
long expireAt = System.currentTimeMillis() + Duration.ofMinutes(5).toMillis();
long now = System.currentTimeMillis();
long expireAt = now + Duration.ofMinutes(LOGIN_CODE_TTL).toMillis();
Map<String, Object> map = new HashMap<>();
map.put("phone", phone);
map.put("code", code);
map.put("sms", sendSmsResponse.getBody().getMessage());
map.put("verifyKey", randomString);
map.put("sentAt", now);
map.put("expireAt", expireAt);
//4.保存验证码到Redis,并且设置有效期5分钟
redisTemplate.opsForValue().set(phone, map, Duration.ofMinutes(5));
if (!sent) {
redisTemplate.opsForValue().set(phone, map, Duration.ofMinutes(5));
}
return Result.ok().data(map);
} else {
return Result.error().message(sendSmsResponse.getBody().getMessage());

View File

@@ -0,0 +1,92 @@
package com.ttstd.videotablet.dto;
import lombok.Data;
import java.util.Date;
/**
* 双Token认证令牌对AccessToken + RefreshToken
* 设计要点:
* 1. 访问令牌短期有效30分钟刷新令牌长期有效7天
* 2. 绑定设备ID防止跨设备滥用[1](@ref)
* 3. 精确控制双Token过期时间
*/
@Data
public class TokenPair {
// 访问令牌用于API请求认证
private String accessToken;
// 刷新令牌用于获取新AccessToken
private String refreshToken;
// AccessToken过期时间戳毫秒
private long accessExpiresAt;
// RefreshToken过期时间戳毫秒
private long refreshExpiresAt;
// 关联设备指纹(防御中间人攻击)[1](@ref)
private String deviceId;
/**
* 全参数构造器(安全增强版)
* @param accessToken JWT格式访问令牌
* @param refreshToken JWT格式刷新令牌
* @param accessExpireMs AccessToken有效期毫秒
* @param refreshExpireMs RefreshToken有效期毫秒
* @param deviceId 客户端设备指纹
*/
public TokenPair(String accessToken, String refreshToken,
long accessExpireMs, long refreshExpireMs,
String deviceId) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.accessExpiresAt = System.currentTimeMillis() + accessExpireMs;
this.refreshExpiresAt = System.currentTimeMillis() + refreshExpireMs;
this.deviceId = deviceId;
}
/**
* 快速创建方法(推荐)
* @param accessToken 访问令牌
* @param refreshToken 刷新令牌
* @param deviceId 设备指纹
* @return 初始化过期时间的TokenPair
*/
public static TokenPair create(String accessToken, String refreshToken, String deviceId) {
return new TokenPair(
accessToken,
refreshToken,
30 * 60 * 1000, // 30分钟有效期
7 * 24 * 60 * 60 * 1000, // 7天有效期
deviceId
);
}
/**
* 检查AccessToken是否过期
* @return true=已过期false=有效
*/
public boolean isAccessExpired() {
return System.currentTimeMillis() > accessExpiresAt;
}
/**
* 检查RefreshToken是否过期
* @return true=已过期false=有效
*/
public boolean isRefreshExpired() {
return System.currentTimeMillis() > refreshExpiresAt;
}
/**
* 安全刷新令牌生成新TokenPair
* @param newAccessToken 新访问令牌
* @param newRefreshToken 新刷新令牌
* @return 更新后的TokenPair保留原设备ID
*/
public TokenPair refresh(String newAccessToken, String newRefreshToken) {
return new TokenPair(
newAccessToken,
newRefreshToken,
this.accessExpiresAt - System.currentTimeMillis(), // 剩余时间延续
this.refreshExpiresAt - System.currentTimeMillis(),
this.deviceId // 保持设备一致性
);
}
}

View File

@@ -0,0 +1,109 @@
package com.ttstd.videotablet.entity;
import jakarta.persistence.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
@Entity
@Table(name = "users")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(unique = true)
private String username;
@Column()
private String password;
@Column(unique = true)
private String email;
@Column(unique = true, nullable = false)
private String phone;
@Column(name = "create_time",unique = true, nullable = false)
private Date creatTime;
// Getters and Setters
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.emptyList();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// Getters and Setters for id, username, password, email
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public Date getCreatTime() {
return creatTime;
}
public void setCreatTime(Date creatTime) {
this.creatTime = creatTime;
}
}

View File

@@ -0,0 +1,13 @@
package com.ttstd.videotablet.repository;
import com.ttstd.videotablet.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
Optional<User> findByPhone(String phone);
boolean existsByPhone(String phone);
}

View File

@@ -0,0 +1,79 @@
package com.ttstd.videotablet.service;
import com.ttstd.videotablet.entity.User;
import com.ttstd.videotablet.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.Map;
@Service
public class UserService implements UserDetailsService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final RedisTemplate<String, Object> redisTemplate;
@Autowired
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, RedisTemplate<String, Object> redisTemplate) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.redisTemplate = redisTemplate;
}
public User registerUser(String username, String password) {
if (userRepository.existsByUsername(username)) {
throw new RuntimeException("Username already exists");
}
User user = new User();
user.setUsername(username);
user.setPassword(passwordEncoder.encode(password));
return userRepository.save(user);
}
public User registerByPhone(String phone, String code, Date createTime) {
// 1. 验证验证码
Map<String, Object> codeMap = (Map<String, Object>) redisTemplate.opsForValue().get(phone);
if (codeMap == null || !code.equals(codeMap.get("code").toString())) {
throw new RuntimeException("Invalid verification code");
}
// 2. 检查手机号是否已注册
if (userRepository.existsByPhone(phone)) {
throw new RuntimeException("Phone number already registered");
}
// 3. 创建新用户
User user = new User();
user.setPhone(phone);
user.setCreatTime(createTime);
return userRepository.save(user);
}
public User loginByPhone(String phone, String code) {
// 1. 验证验证码
Map<String, Object> codeMap = (Map<String, Object>) redisTemplate.opsForValue().get(phone);
if (codeMap == null || !code.equals(codeMap.get("code").toString())) {
throw new RuntimeException("Invalid verification code");
}
// 2. 查询用户
return userRepository.findByPhone(phone)
.orElseThrow(() -> new RuntimeException("User not found with phone: " + phone));
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username));
}
}

View File

@@ -0,0 +1,57 @@
package com.ttstd.videotablet.utils;
import com.ttstd.videotablet.dto.TokenPair;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Component
public class JwtUtil {
@Autowired
private StringRedisTemplate redisTemplate;
@Value("${jwt.secret}") private String secret;
@Value("${jwt.access-expire}") private Long accessExpire;
@Value("${jwt.refresh-expire}") private Long refreshExpire;
// 生成双Token关联设备指纹
public TokenPair generateTokenPair(String username, String deviceId) {
String accessToken = Jwts.builder()
.subject(username)
.claim("type", "ACCESS")
.claim("deviceId", deviceId) // 绑定设备
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessExpire))
.signWith(Keys.hmacShaKeyFor(secret.getBytes()))
.compact();
String refreshId = UUID.randomUUID().toString();
String refreshToken = Jwts.builder()
.subject(username)
.claim("type", "REFRESH")
.claim("refreshId", refreshId)
.expiration(new Date(System.currentTimeMillis() + refreshExpire))
.signWith(Keys.hmacShaKeyFor(secret.getBytes()))
.compact();
// Redis存储Refresh Tokenkey: user:refresh:<username>, value: refreshId
redisTemplate.opsForValue().set(
"user:refresh:" + username,
refreshId,
refreshExpire,
TimeUnit.MILLISECONDS
);
return new TokenPair(accessToken, refreshToken);
}
// Token解析与校验
}

View File

@@ -21,4 +21,10 @@ spring.data.redis.lettuce.pool.min-idle=5
spring.data.redis.lettuce.pool.max-idle=10
spring.data.redis.lettuce.pool.max-active=8
spring.data.redis.lettuce.pool.max-wait=1ms
spring.data.redis.lettuce.shutdown-timeout=100ms
spring.data.redis.lettuce.shutdown-timeout=100ms
# Hibernate配置
#spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true