From f53e3635d0c26295076bdafea60701a1f1e3fdf2 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:17:54 +0800 Subject: [PATCH] Initialize Spring Boot backend with JWT auth --- README.md | 63 ++++++++++++- pom.xml | 74 +++++++++++++++ .../com/openisle/OpenIsleApplication.java | 11 +++ .../com/openisle/config/SecurityConfig.java | 92 +++++++++++++++++++ .../openisle/controller/AuthController.java | 52 +++++++++++ .../openisle/controller/HelloController.java | 12 +++ src/main/java/com/openisle/model/User.java | 26 ++++++ .../openisle/repository/UserRepository.java | 10 ++ .../com/openisle/service/EmailService.java | 38 ++++++++ .../java/com/openisle/service/JwtService.java | 45 +++++++++ .../com/openisle/service/UserService.java | 33 +++++++ src/main/resources/application.properties | 9 ++ 12 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 pom.xml create mode 100644 src/main/java/com/openisle/OpenIsleApplication.java create mode 100644 src/main/java/com/openisle/config/SecurityConfig.java create mode 100644 src/main/java/com/openisle/controller/AuthController.java create mode 100644 src/main/java/com/openisle/controller/HelloController.java create mode 100644 src/main/java/com/openisle/model/User.java create mode 100644 src/main/java/com/openisle/repository/UserRepository.java create mode 100644 src/main/java/com/openisle/service/EmailService.java create mode 100644 src/main/java/com/openisle/service/JwtService.java create mode 100644 src/main/java/com/openisle/service/UserService.java create mode 100644 src/main/resources/application.properties diff --git a/README.md b/README.md index 1bda8c9dc..642499050 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,63 @@ # OpenIsle -开源的社区后端平台 Open source community backend platform. + +OpenIsle 是一个基于 Spring Boot 的社区后端平台示例,提供注册、登录和基于 JWT 的认证功能,支持使用 MySQL 作为数据库,并通过 [Resend](https://resend.com) API 发送注册邮件。 + +## 功能特性 + +- **注册/登录**:用户可以注册并登录,密码使用 BCrypt 加密保存。 +- **JWT 认证**:登录成功后返回 JWT,后续请求需在 `Authorization` 头中携带 `Bearer` token。 +- **邮件通知**:示例通过 Resend API 发送欢迎邮件,可根据需要修改。 +- **灵活配置**:数据库地址、账户密码、Resend API Key 等均可通过环境变量或 `application.properties` 配置。 + +## 快速开始 + +### 环境准备 + +- Java 17+ +- Maven 3+ +- MySQL 数据库 + +### 构建与运行 + +1. 修改 `src/main/resources/application.properties`,或通过环境变量配置: + - `MYSQL_URL`:数据库连接 URL,例如 `jdbc:mysql://localhost:3306/openisle`。 + - `MYSQL_USER`:数据库用户名。 + - `MYSQL_PASSWORD`:数据库密码。 + - `RESEND_API_KEY`:Resend 邮件服务 API Key。 + - `JWT_SECRET`:JWT 签名密钥。 + - `JWT_EXPIRATION`:JWT 过期时间(毫秒)。 + +2. 构建并运行: + +```bash +mvn spring-boot:run +``` + +启动后访问: + +- `POST /api/auth/register`:注册新用户,参数示例: + ```json + { + "username": "test", + "email": "test@example.com", + "password": "password" + } + ``` +- `POST /api/auth/login`:登录,返回 `{ "token": "..." }`。 +- 其他受保护接口示例:`GET /api/hello`,需在请求头加入 `Authorization: Bearer `。 + +## 目录结构 + +``` +src/main/java/com/openisle +├── OpenIsleApplication.java // 应用入口 +├── config // Spring Security 配置 +├── controller // 控制器 +├── model // 数据模型 +├── repository // 数据访问层 +└── service // 业务逻辑 +``` + +## 许可 + +本项目使用 MIT License,可自由修改和分发。 diff --git a/pom.xml b/pom.xml new file mode 100644 index 000000000..adf3f5cda --- /dev/null +++ b/pom.xml @@ -0,0 +1,74 @@ + + 4.0.0 + + com.openisle + openisle + 0.0.1-SNAPSHOT + jar + + OpenIsle + Open source community backend platform + + + org.springframework.boot + spring-boot-starter-parent + 3.1.1 + + + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + com.mysql + mysql-connector-j + runtime + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + org.projectlombok + lombok + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/src/main/java/com/openisle/OpenIsleApplication.java b/src/main/java/com/openisle/OpenIsleApplication.java new file mode 100644 index 000000000..ba2421898 --- /dev/null +++ b/src/main/java/com/openisle/OpenIsleApplication.java @@ -0,0 +1,11 @@ +package com.openisle; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class OpenIsleApplication { + public static void main(String[] args) { + SpringApplication.run(OpenIsleApplication.class, args); + } +} diff --git a/src/main/java/com/openisle/config/SecurityConfig.java b/src/main/java/com/openisle/config/SecurityConfig.java new file mode 100644 index 000000000..18df57da8 --- /dev/null +++ b/src/main/java/com/openisle/config/SecurityConfig.java @@ -0,0 +1,92 @@ +package com.openisle.config; + +import com.openisle.service.JwtService; +import com.openisle.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +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.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + private final JwtService jwtService; + private final UserRepository userRepository; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public UserDetailsService userDetailsService() { + return username -> userRepository.findByUsername(username) + .map(user -> org.springframework.security.core.userdetails.User + .withUsername(user.getUsername()) + .password(user.getPassword()) + .authorities("USER") + .build()) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + } + + @Bean + public AuthenticationManager authenticationManager(HttpSecurity http, PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) throws Exception { + return http.getSharedObject(AuthenticationManagerBuilder.class) + .userDetailsService(userDetailsService) + .passwordEncoder(passwordEncoder) + .and() + .build(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public OncePerRequestFilter jwtAuthenticationFilter() { + return new OncePerRequestFilter() { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + try { + String username = jwtService.validateAndGetSubject(token); + UserDetails userDetails = userDetailsService().loadUserByUsername(username); + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(authToken); + } catch (Exception ignored) { + } + } + filterChain.doFilter(request, response); + } + }; + } +} diff --git a/src/main/java/com/openisle/controller/AuthController.java b/src/main/java/com/openisle/controller/AuthController.java new file mode 100644 index 000000000..bb58b028a --- /dev/null +++ b/src/main/java/com/openisle/controller/AuthController.java @@ -0,0 +1,52 @@ +package com.openisle.controller; + +import com.openisle.model.User; +import com.openisle.service.EmailService; +import com.openisle.service.JwtService; +import com.openisle.service.UserService; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + private final UserService userService; + private final JwtService jwtService; + private final EmailService emailService; + + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegisterRequest req) { + User user = userService.register(req.getUsername(), req.getEmail(), req.getPassword()); + emailService.sendEmail(user.getEmail(), "Welcome to OpenIsle", "Thank you for registering."); + String token = jwtService.generateToken(user.getUsername()); + return ResponseEntity.ok(new JwtResponse(token)); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest req) { + return userService.authenticate(req.getUsername(), req.getPassword()) + .map(user -> ResponseEntity.ok(new JwtResponse(jwtService.generateToken(user.getUsername())))) + .orElse(ResponseEntity.status(401).build()); + } + + @Data + private static class RegisterRequest { + private String username; + private String email; + private String password; + } + + @Data + private static class LoginRequest { + private String username; + private String password; + } + + @Data + private static class JwtResponse { + private final String token; + } +} diff --git a/src/main/java/com/openisle/controller/HelloController.java b/src/main/java/com/openisle/controller/HelloController.java new file mode 100644 index 000000000..e1133b521 --- /dev/null +++ b/src/main/java/com/openisle/controller/HelloController.java @@ -0,0 +1,12 @@ +package com.openisle.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HelloController { + @GetMapping("/api/hello") + public String hello() { + return "Hello, Authenticated User"; + } +} diff --git a/src/main/java/com/openisle/model/User.java b/src/main/java/com/openisle/model/User.java new file mode 100644 index 000000000..b3d0c407b --- /dev/null +++ b/src/main/java/com/openisle/model/User.java @@ -0,0 +1,26 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "users") +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String username; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String password; +} diff --git a/src/main/java/com/openisle/repository/UserRepository.java b/src/main/java/com/openisle/repository/UserRepository.java new file mode 100644 index 000000000..79f3db1f9 --- /dev/null +++ b/src/main/java/com/openisle/repository/UserRepository.java @@ -0,0 +1,10 @@ +package com.openisle.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import com.openisle.model.User; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + Optional findByEmail(String email); +} diff --git a/src/main/java/com/openisle/service/EmailService.java b/src/main/java/com/openisle/service/EmailService.java new file mode 100644 index 000000000..bbf11f896 --- /dev/null +++ b/src/main/java/com/openisle/service/EmailService.java @@ -0,0 +1,38 @@ +package com.openisle.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class EmailService { + + @Value("${resend.api.key}") + private String apiKey; + + private final RestTemplate restTemplate = new RestTemplate(); + + public void sendEmail(String to, String subject, String text) { + String url = "https://api.resend.com/emails"; // hypothetical endpoint + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + apiKey); + + Map body = new HashMap<>(); + body.put("to", to); + body.put("subject", subject); + body.put("text", text); + body.put("from", "demo@openisle.example"); + + HttpEntity> entity = new HttpEntity<>(body, headers); + restTemplate.exchange(url, HttpMethod.POST, entity, String.class); + } +} diff --git a/src/main/java/com/openisle/service/JwtService.java b/src/main/java/com/openisle/service/JwtService.java new file mode 100644 index 000000000..d27e13dac --- /dev/null +++ b/src/main/java/com/openisle/service/JwtService.java @@ -0,0 +1,45 @@ +package com.openisle.service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.security.Key; +import java.util.Date; + +@Service +public class JwtService { + + @Value("${app.jwt.secret}") + private String secret; + + @Value("${app.jwt.expiration}") + private long expiration; + + private Key getSigningKey() { + return Keys.hmacShaKeyFor(secret.getBytes()); + } + + public String generateToken(String subject) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration); + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public String validateAndGetSubject(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + return claims.getSubject(); + } +} diff --git a/src/main/java/com/openisle/service/UserService.java b/src/main/java/com/openisle/service/UserService.java new file mode 100644 index 000000000..3ec0bba60 --- /dev/null +++ b/src/main/java/com/openisle/service/UserService.java @@ -0,0 +1,33 @@ +package com.openisle.service; + +import com.openisle.model.User; +import com.openisle.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + public User register(String username, String email, String password) { + if (userRepository.findByUsername(username).isPresent() || userRepository.findByEmail(email).isPresent()) { + throw new RuntimeException("User already exists"); + } + User user = new User(); + user.setUsername(username); + user.setEmail(email); + user.setPassword(passwordEncoder.encode(password)); + return userRepository.save(user); + } + + public Optional authenticate(String username, String password) { + return userRepository.findByUsername(username) + .filter(user -> passwordEncoder.matches(password, user.getPassword())); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 000000000..b342455a7 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,9 @@ +spring.datasource.url=${MYSQL_URL:jdbc:mysql://localhost:3306/openisle} +spring.datasource.username=${MYSQL_USER:root} +spring.datasource.password=${MYSQL_PASSWORD:password} +spring.jpa.hibernate.ddl-auto=update + +resend.api.key=${RESEND_API_KEY:} + +app.jwt.secret=${JWT_SECRET:ChangeThisSecretKeyForJwt} +app.jwt.expiration=${JWT_EXPIRATION:86400000}