一、功能概述
個人博客系統的注冊登錄功能包括:
- 用戶注冊:新用戶可以通過提供用戶名、密碼、郵箱等信息創建賬號
- 用戶登錄:已注冊用戶可以通過用戶名和密碼進行身份驗證,獲取JWT令牌
- 身份驗證:使用JWT令牌訪問需要認證的API
二、技術棧
- 后端框架:Spring Boot 3.2.5
- 安全框架:Spring Security
- 數據庫:MySQL 8.0
- 認證方式:JWT (JSON Web Token)
- API測試工具:Postman
三、實現步驟
1. 數據庫設計
用戶表(users)設計:
CREATE TABLE users (id BIGINT AUTO_INCREMENT PRIMARY KEY,username VARCHAR(50) NOT NULL UNIQUE,password VARCHAR(100) NOT NULL,email VARCHAR(100) NOT NULL UNIQUE,nickname VARCHAR(50),role VARCHAR(20) NOT NULL DEFAULT 'USER',status INT NOT NULL DEFAULT 1,created_at DATETIME NOT NULL,updated_at DATETIME NOT NULL
);
2. 實體類設計
User實體類:
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "users")
public class User {@Id@GeneratedValue(strategy = GenerationType.IDENTITY)private Long id;@Column(nullable = false, unique = true, length = 50)private String username;@Column(nullable = false, length = 100)private String password;@Column(nullable = false, unique = true, length = 100)private String email;@Column(length = 50)private String nickname;@Column(nullable = false, length = 20)private String role = "USER"; // 默認角色@Column(nullable = false)private Integer status = 1; // 默認狀態(1為激活)@Column(name = "created_at", nullable = false, updatable = false)private LocalDateTime createdAt;@Column(name = "updated_at", nullable = false)private LocalDateTime updatedAt;@PrePersistprotected void onCreate() {createdAt = LocalDateTime.now();updatedAt = LocalDateTime.now();}@PreUpdateprotected void onUpdate() {updatedAt = LocalDateTime.now();}
}
3. DTO設計
注冊DTO:
public class RegisterUserDto {@NotBlank(message = "用戶名不能為空")@Size(min = 4, max = 50, message = "用戶名長度必須在4-50個字符之間")private String username;@NotBlank(message = "密碼不能為空")@Size(min = 6, max = 100, message = "密碼長度必須在6-100個字符之間")private String password;@NotBlank(message = "郵箱不能為空")@Email(message = "郵箱格式不正確")private String email;private String nickname;// getters and setters
}
登錄DTO:
public class LoginUserDto {@NotBlank(message = "用戶名不能為空")private String username;@NotBlank(message = "密碼不能為空")private String password;// getters and setters
}
4. 數據倉庫接口
@Repository
public interface UserRepository extends JpaRepository<User, Long> {Optional<User> findByUsername(String username);Optional<User> findByEmail(String email);boolean existsByUsername(String username);boolean existsByEmail(String email);
}
5. 服務層實現
AuthService接口:
public interface AuthService {User registerUser(RegisterUserDto registerUserDto);Map<String, Object> loginUser(LoginUserDto loginUserDto) throws AuthenticationException;
}
AuthServiceImpl實現類:
@Service
public class AuthServiceImpl implements AuthService {private final UserRepository userRepository;private final PasswordEncoder passwordEncoder;private final AuthenticationManager authenticationManager;private final JwtUtil jwtUtil;@Autowiredpublic AuthServiceImpl(UserRepository userRepository,PasswordEncoder passwordEncoder,AuthenticationManager authenticationManager,JwtUtil jwtUtil) {this.userRepository = userRepository;this.passwordEncoder = passwordEncoder;this.authenticationManager = authenticationManager;this.jwtUtil = jwtUtil;}@Override@Transactionalpublic User registerUser(RegisterUserDto registerUserDto) {// 檢查用戶名是否已存在if (userRepository.existsByUsername(registerUserDto.getUsername())) {throw new UserAlreadyExistsException("用戶名 " + registerUserDto.getUsername() + " 已被注冊");}// 檢查郵箱是否已存在if (registerUserDto.getEmail() != null && !registerUserDto.getEmail().isEmpty() && userRepository.existsByEmail(registerUserDto.getEmail())) {throw new UserAlreadyExistsException("郵箱 " + registerUserDto.getEmail() + " 已被注冊");}// 創建新用戶實體User newUser = new User();newUser.setUsername(registerUserDto.getUsername());// 加密密碼newUser.setPassword(passwordEncoder.encode(registerUserDto.getPassword()));newUser.setEmail(registerUserDto.getEmail());newUser.setNickname(registerUserDto.getNickname());// 使用默認值(role="USER", status=1)// createdAt 和 updatedAt 由 @PrePersist 自動處理// 保存用戶到數據庫return userRepository.save(newUser);}@Overridepublic Map<String, Object> loginUser(LoginUserDto loginUserDto) throws AuthenticationException {// 創建認證令牌UsernamePasswordAuthenticationToken authenticationToken =new UsernamePasswordAuthenticationToken(loginUserDto.getUsername(), loginUserDto.getPassword());// 進行認證Authentication authentication = authenticationManager.authenticate(authenticationToken);SecurityContextHolder.getContext().setAuthentication(authentication);// 獲取用戶詳情UserDetails userDetails = (UserDetails) authentication.getPrincipal();// 生成JWT令牌String jwt = jwtUtil.generateToken(userDetails);// 獲取用戶IDUser user = userRepository.findByUsername(loginUserDto.getUsername()).orElseThrow(() -> new RuntimeException("用戶不存在"));// 創建返回結果Map<String, Object> result = new HashMap<>();result.put("token", jwt);result.put("userId", user.getId());result.put("username", user.getUsername());result.put("expiresIn", 604800L); // 默認7天 = 604800秒return result;}
}
6. 控制器實現
@RestController
@RequestMapping("/auth")
public class AuthController {private final AuthService authService;@Autowiredpublic AuthController(AuthService authService) {this.authService = authService;}@PostMapping("/register")public ResponseEntity<?> registerUser(@Valid @RequestBody RegisterUserDto registerUserDto) {User registeredUser = authService.registerUser(registerUserDto);Map<String, Object> response = new HashMap<>();response.put("code", HttpStatus.CREATED.value());response.put("message", "注冊成功");return ResponseEntity.status(HttpStatus.CREATED).body(response);}@PostMapping("/login")public ResponseEntity<?> loginUser(@Valid @RequestBody LoginUserDto loginUserDto) {try {Map<String, Object> loginResult = authService.loginUser(loginUserDto);Map<String, Object> response = new HashMap<>();response.put("code", HttpStatus.OK.value());response.put("message", "登錄成功");Map<String, Object> data = new HashMap<>();data.put("token", loginResult.get("token"));data.put("userId", loginResult.get("userId"));data.put("username", loginResult.get("username"));data.put("expiresIn", loginResult.get("expiresIn"));response.put("data", data);return ResponseEntity.ok(response);} catch (BadCredentialsException e) {Map<String, Object> response = new HashMap<>();response.put("code", HttpStatus.UNAUTHORIZED.value());response.put("message", "用戶名或密碼錯誤");return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);} catch (Exception e) {Map<String, Object> response = new HashMap<>();response.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value());response.put("message", "服務器內部錯誤: " + e.getMessage());return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);}}@ExceptionHandler(MethodArgumentNotValidException.class)@ResponseStatus(HttpStatus.BAD_REQUEST)public Map<String, Object> handleValidationExceptions(MethodArgumentNotValidException ex) {Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));Map<String, Object> response = new HashMap<>();response.put("code", HttpStatus.BAD_REQUEST.value());response.put("message", "請求參數錯誤");response.put("errors", errors);return response;}@ExceptionHandler(UserAlreadyExistsException.class)@ResponseStatus(HttpStatus.BAD_REQUEST)public Map<String, Object> handleUserAlreadyExistsException(UserAlreadyExistsException ex) {Map<String, Object> response = new HashMap<>();response.put("code", HttpStatus.BAD_REQUEST.value());response.put("message", ex.getMessage());return response;}
}
7. 安全配置
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {@Autowiredprivate UserDetailsServiceImpl userDetailsService;@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}@Beanpublic AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {return authenticationConfiguration.getAuthenticationManager();}@Beanpublic SecurityFilterChain filterChain(HttpSecurity http) throws Exception {http.csrf(AbstractHttpConfigurer::disable).exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)).accessDeniedHandler((request, response, accessDeniedException) -> response.setStatus(HttpStatus.FORBIDDEN.value()))).authorizeHttpRequests(authz -> authz.requestMatchers("/auth/**").permitAll().anyRequest().authenticated()).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));return http.build();}
}
8. JWT工具類
@Component
public class JwtUtil {@Value("${jwt.secret}")private String secret;@Value("${jwt.expiration}")private Long expiration;public String generateToken(UserDetails userDetails) {Map<String, Object> claims = new HashMap<>();return createToken(claims, userDetails.getUsername());}private String createToken(Map<String, Object> claims, String subject) {Date now = new Date();Date expiryDate = new Date(now.getTime() + expiration);return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(now).setExpiration(expiryDate).signWith(SignatureAlgorithm.HS512, secret).compact();}// 其他JWT驗證方法...
}
四、使用Postman測試注冊登錄功能
1. 測試用戶注冊
-
創建POST請求:
- URL:
http://localhost:8080/auth/register
- 請求頭:
Content-Type: application/json
- 請求體:
{"username": "testuser","password": "Password123","email": "testuser@example.com","nickname": "測試用戶" }
- URL:
-
發送請求并驗證響應:
- 成功響應(201 Created):
{"code": 201,"message": "注冊成功" }
- 失敗響應(400 Bad Request):
{"code": 400,"message": "用戶名 testuser 已被注冊" }
2. 測試用戶登錄
-
創建POST請求:
- URL:
http://localhost:8080/auth/login
- 請求頭:
Content-Type: application/json
- 請求體:
{"username": "testuser","password": "Password123" }
- URL:
-
發送請求并驗證響應:
- 成功響應(200 OK):
{"code": 200,"message": "登錄成功","data": {"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...","userId": 1,"username": "testuser","expiresIn": 604800} }
- 失敗響應(401 Unauthorized):
{"code": 401,"message": "用戶名或密碼錯誤" }
3. 使用JWT令牌訪問受保護的API
-
創建請求(例如獲取用戶信息):
- URL:
http://localhost:8080/users/1
- 請求頭:
Authorization: Bearer {token}
(使用登錄時獲取的token)
- URL:
-
發送請求并驗證響應
五、常見問題及解決方案
1. Java 9+中缺少javax.xml.bind問題
問題描述:在Java 9及以上版本中使用JJWT 0.9.1庫時,可能會遇到以下錯誤:
java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter
原因:從Java 9開始,Java EE模塊(包括javax.xml.bind包)被移除出了JDK核心。
解決方案:在pom.xml中添加JAXB API依賴:
<!-- 添加JAXB API依賴,解決Java 9+中缺少javax.xml.bind問題 -->
<dependency><groupId>javax.xml.bind</groupId><artifactId>jaxb-api</artifactId><version>2.3.1</version>
</dependency>
<dependency><groupId>com.sun.xml.bind</groupId><artifactId>jaxb-impl</artifactId><version>2.3.1</version>
</dependency>
<dependency><groupId>com.sun.xml.bind</groupId><artifactId>jaxb-core</artifactId><version>2.3.0.1</version>
</dependency>
2. API路徑不匹配問題
問題描述:README文檔中描述的API路徑與實際代碼中的路徑不匹配。
原因:README中描述的基礎路徑是http://localhost:8080/api/v1
,但控制器中只配置了/auth
路徑。
解決方案:
-
方案一:使用正確的URL:
http://localhost:8080/auth/register
和http://localhost:8080/auth/login
-
方案二:在application.properties中添加上下文路徑配置:
server.servlet.context-path=/api/v1
這樣就可以使用README中描述的URL:
http://localhost:8080/api/v1/auth/register
和http://localhost:8080/api/v1/auth/login
3. 數據庫連接問題
問題描述:注冊接口返回成功,但數據庫中沒有保存數據。
可能原因:
- 數據庫名稱配置錯誤
- 事務回滾(可能由未捕獲的異常引起)
- 數據庫連接問題
解決方案:
-
檢查application.properties中的數據庫配置是否正確:
spring.datasource.url=jdbc:mysql://localhost:3306/weblog?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true spring.datasource.username=root spring.datasource.password=123456
-
確保數據庫存在并且可以連接
-
檢查日志中是否有事務回滾的錯誤信息
4. 請求驗證失敗
問題描述:注冊或登錄請求返回400錯誤,但沒有明確的錯誤信息。
可能原因:請求體中缺少必填字段或格式不正確。
解決方案:
- 確保請求體中包含所有必填字段
- 確保字段格式正確(例如,郵箱格式、密碼長度等)
- 檢查控制臺日志,查看詳細的驗證錯誤信息
六、最佳實踐
-
密碼安全:
- 始終使用BCrypt等安全的密碼哈希算法
- 不要在響應中返回密碼,即使是加密后的密碼
- 設置密碼復雜度要求(長度、特殊字符等)
-
JWT安全:
- 使用強密鑰(至少256位)
- 設置合理的過期時間
- 考慮實現令牌刷新機制
- 在生產環境中使用HTTPS
-
異常處理:
- 為不同類型的異常提供明確的錯誤消息
- 不要在生產環境中暴露敏感的技術細節
- 使用統一的響應格式
-
日志記錄:
- 記錄關鍵操作(注冊、登錄、登出)
- 記錄異常和錯誤
- 不要記錄敏感信息(密碼、令牌等)