diff --git a/src/main/java/me/gt/snaptickets/controller/AdminUserController.java b/src/main/java/me/gt/snaptickets/controller/AdminUserController.java new file mode 100644 index 0000000..ef10b83 --- /dev/null +++ b/src/main/java/me/gt/snaptickets/controller/AdminUserController.java @@ -0,0 +1,98 @@ +package me.gt.snaptickets.controller; + +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import me.gt.snaptickets.dto.AdminUserDto; +import me.gt.snaptickets.model.AdminUser; +import me.gt.snaptickets.service.AdminUserService; +import me.gt.snaptickets.util.PasswordUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@CrossOrigin +@RestController +@Hidden +@RequestMapping("/admin") +public class AdminUserController { + + @Autowired + private AdminUserService userService; + + @Value("${jwt.admin.secret}") + private String jwtSecret; + + @Operation(summary = "註冊管理員帳號") + @PostMapping("/auth/register") + public ResponseEntity registerUser(@RequestBody AdminUserDto adminUserDto) { + String validationMessage = PasswordUtil.validatePassword(adminUserDto.getPassword()); // 驗證密碼是否符合安全性要求 + if (validationMessage != null) { + return ResponseEntity.badRequest().body(validationMessage); + } + AdminUserService.RegistrationStatus status = userService.registerAdminUser(adminUserDto.convertToUser()); + return switch (status) { + case USERNAME_EXISTS -> ResponseEntity.badRequest().body("此帳號已存在"); + case EMAIL_EXISTS -> ResponseEntity.badRequest().body("此信箱已被註冊"); + default -> ResponseEntity.ok("註冊成功"); + }; + } + + @Operation(summary = "管理員帳號登入") + @PostMapping("/auth/login") + public ResponseEntity loginUser(@RequestParam String identifier, @RequestParam String password) { + AdminUser user = userService.loginUser(identifier, password); + if (user == null) { + return ResponseEntity.badRequest().body("帳號或密碼錯誤"); + } + String token = PasswordUtil.generateJwtToken(user,jwtSecret); + return ResponseEntity.ok().body(Map.of("username", user.getUsername(), "token", token)); + } + + @Operation(summary = "透過帳號查詢資料") + @GetMapping("/user/info/{username}") + public ResponseEntity getUserByUsername(@PathVariable String username) { + AdminUser user = userService.getByUsername(username); + if (user == null) { + return ResponseEntity.badRequest().body("查無此帳號"); + } + return ResponseEntity.ok(AdminUserDto.fromUser(user)); + } + + @Operation(summary = "更新帳號資訊") + @PutMapping("/user") + public ResponseEntity updateUser(@RequestBody AdminUserDto user) { + boolean success = userService.updateUser(user.convertToUser()); + if (success) { + return ResponseEntity.ok("更改資料成功"); + } + return ResponseEntity.badRequest().body("更改資料失敗"); + } + + @Operation(summary = "更改帳號密碼") + @PutMapping("/change-password/{username}") + public ResponseEntity updatePassword(@PathVariable String username, @RequestParam String oldPassword, @RequestParam String newPassword) { + String validationMessage = PasswordUtil.validatePassword(newPassword); // 驗證新密碼是否符合安全性要求 + if (validationMessage != null) { + return ResponseEntity.badRequest().body(validationMessage); + } + AdminUserService.AuthStatus status = userService.updatePassword(username, oldPassword, newPassword); + return switch (status) { + case USER_NOT_FOUND -> ResponseEntity.badRequest().body("帳號不存在"); + case PASSWORD_INCORRECT -> ResponseEntity.badRequest().body("舊密碼不正確"); + case PASSWORD_SAME -> ResponseEntity.badRequest().body("新密碼與舊密碼相同"); + default -> ResponseEntity.ok("更改密碼成功"); + }; + } + + @Operation(summary = "刪除帳號") + @DeleteMapping("/user/{username}") + public ResponseEntity deleteUser(@PathVariable String username) { + userService.deleteUser(username); + return ResponseEntity.ok("帳號已刪除"); + } + +} diff --git a/src/main/java/me/gt/snaptickets/dto/AdminUserDto.java b/src/main/java/me/gt/snaptickets/dto/AdminUserDto.java new file mode 100644 index 0000000..f9a45a3 --- /dev/null +++ b/src/main/java/me/gt/snaptickets/dto/AdminUserDto.java @@ -0,0 +1,53 @@ +package me.gt.snaptickets.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; +import me.gt.snaptickets.model.AdminUser; + +@Data +@Builder +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class AdminUserDto { + + @Schema(description = "帳號") + private String username; + + @Schema(description = "密碼 (需包含大小寫字母、數字、特殊字元,且長度至少8)", accessMode = Schema.AccessMode.WRITE_ONLY) + private String password; + + @Schema(description = "姓名") + private String name; + + @Schema(description = "電子郵件") + private String email; + + /** + * 轉換成 AdminUser 物件 + * @return AdminUser + */ + public AdminUser convertToUser() { + return AdminUser.builder() + .username(username) + .password(password) + .name(name) + .email(email) + .build(); + } + + /** + * 從 User 物件轉換成 AdminUserDto 物件 + * + * @param user + * + * @return AdminUserDto + */ + public static AdminUserDto fromUser(AdminUser user) { + return AdminUserDto.builder() + .username(user.getUsername()) + .name(user.getName()) + .email(user.getEmail()) + .build(); + } +} diff --git a/src/main/java/me/gt/snaptickets/filter/AdminJwtAuthenticationFilter.java b/src/main/java/me/gt/snaptickets/filter/AdminJwtAuthenticationFilter.java new file mode 100644 index 0000000..63d6728 --- /dev/null +++ b/src/main/java/me/gt/snaptickets/filter/AdminJwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package me.gt.snaptickets.filter; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import me.gt.snaptickets.service.AdminUserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; + +public class AdminJwtAuthenticationFilter extends OncePerRequestFilter { + + @Value("${jwt.admin.secret}") + private String jwtSecret; + + @Autowired + private AdminUserService adminUserService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String header = request.getHeader("Admin-Authorization"); + if (header != null && header.startsWith("Bearer ")) { + String token = header.substring(7); + try { + Claims claims = Jwts.parser() + .setSigningKey(jwtSecret) + .build() + .parseClaimsJws(token) + .getBody(); + + String username = claims.getSubject(); + String password = (String) claims.get("password"); + if (username != null && password != null) { + if (!adminUserService.verifyTokenLogin(username,password)) { // Token 異常 登入密碼不對 + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null, new ArrayList<>()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + e.printStackTrace(); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return; + } + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/me/gt/snaptickets/mapper/AdminUserMapper.java b/src/main/java/me/gt/snaptickets/mapper/AdminUserMapper.java new file mode 100644 index 0000000..ec392e6 --- /dev/null +++ b/src/main/java/me/gt/snaptickets/mapper/AdminUserMapper.java @@ -0,0 +1,27 @@ +package me.gt.snaptickets.mapper; + +import me.gt.snaptickets.model.AdminUser; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface AdminUserMapper { + + @Insert("INSERT INTO admins (username, password, name, email, permission) " + + "VALUES (#{username}, #{password}, #{name}, #{email}, #{permission})") + void register(AdminUser user); + + @Select("SELECT * FROM admins WHERE username = #{username}") + AdminUser getByUsername(String username); + + @Select("SELECT * FROM admins WHERE email = #{email}") + AdminUser getByEmail(String email); + + @Update("UPDATE admins SET password = #{password} WHERE username = #{username}") + void updatePassword(String username, String password); + + @Update("UPDATE admins SET name = #{name}, email = #{email}, permission = #{permission} WHERE username = #{username}") + int updateUser(AdminUser user); + + @Delete("DELETE FROM admins WHERE username = #{username}") + void deleteUser(String username); +} diff --git a/src/main/java/me/gt/snaptickets/model/AdminUser.java b/src/main/java/me/gt/snaptickets/model/AdminUser.java new file mode 100644 index 0000000..dc6002e --- /dev/null +++ b/src/main/java/me/gt/snaptickets/model/AdminUser.java @@ -0,0 +1,40 @@ +package me.gt.snaptickets.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.time.LocalDateTime; + +@Data +@Builder +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class AdminUser { + + @Schema(description = "帳號") + private String username; + + @Schema(description = "密碼") + @JsonIgnore + private String password; + + @Schema(description = "姓名") + private String name; + + @Schema(description = "電子郵件") + private String email; + + @Schema(description = "權限") + @Builder.Default + private Permission permission = Permission.MOD; + + @Schema(description = "建立日期") + @Builder.Default + private LocalDateTime createdAt = LocalDateTime.now(); + + enum Permission { + ADMIN, MOD + } +} diff --git a/src/main/java/me/gt/snaptickets/service/AdminUserService.java b/src/main/java/me/gt/snaptickets/service/AdminUserService.java new file mode 100644 index 0000000..0a90210 --- /dev/null +++ b/src/main/java/me/gt/snaptickets/service/AdminUserService.java @@ -0,0 +1,86 @@ +package me.gt.snaptickets.service; + +import me.gt.snaptickets.model.AdminUser; + +public interface AdminUserService { + + /** + * 註冊會員 + * + * @param adminUser 管理員帳號 + * @return 註冊狀態 + */ + RegistrationStatus registerAdminUser(AdminUser adminUser); + + /** + * 透過管理員名稱查詢管理員 + * + * @param identifier 管理員帳號(帳號或信箱) + * @return 管理員資訊 + */ + AdminUser loginUser(String identifier, String password); + + + /** + * 管理員Token登入 + * + * @param username 會員名稱 + * @param password 會員密碼 + * @return 驗證狀態 true: 通過 false: 失敗 + */ + boolean verifyTokenLogin(String username, String password); + + /** + * 透過管理員名稱查詢管理員 + * + * @param username 管理員名稱 + * @return 管理員資訊 + */ + AdminUser getByUsername(String username); + + /** + * 更新管理員資訊 + * + * @param adminUser 管理員資訊 + * + * @return 更新狀態 + */ + boolean updateUser(AdminUser adminUser); + + /** + * 更新管理員密碼 + * + * @param username 管理員名稱 + * @param oldPassword 管理員舊密碼 + * @param newPassword 管理員新密碼 + * + * @return 帳號狀態 + */ + AuthStatus updatePassword(String username, String oldPassword, String newPassword); + + /** + * 刪除管理員 + * + * @param username 管理員名稱 + */ + void deleteUser(String username); + + /** + * 註冊狀態 + */ + enum RegistrationStatus { + USERNAME_EXISTS, + EMAIL_EXISTS, + SUCCESS + } + + /** + * 登入狀態 + */ + enum AuthStatus { + USER_NOT_FOUND, + PASSWORD_INCORRECT, + PASSWORD_SAME, + SUCCESS + } +} diff --git a/src/main/java/me/gt/snaptickets/service/UserService.java b/src/main/java/me/gt/snaptickets/service/UserService.java index 2aeb626..0fdf931 100644 --- a/src/main/java/me/gt/snaptickets/service/UserService.java +++ b/src/main/java/me/gt/snaptickets/service/UserService.java @@ -21,6 +21,15 @@ public interface UserService { */ User loginUser(String identifier, String password); + /** + * 會員Token登入 + * + * @param username 會員名稱 + * @param password 會員密碼 + * @return 驗證狀態 true: 通過 false: 失敗 + */ + boolean verifyTokenLogin(String username, String password); + /** * 透過會員名稱查詢會員 * diff --git a/src/main/java/me/gt/snaptickets/service/impl/AdminUserServiceImpl.java b/src/main/java/me/gt/snaptickets/service/impl/AdminUserServiceImpl.java new file mode 100644 index 0000000..5e57d67 --- /dev/null +++ b/src/main/java/me/gt/snaptickets/service/impl/AdminUserServiceImpl.java @@ -0,0 +1,91 @@ +package me.gt.snaptickets.service.impl; + +import me.gt.snaptickets.mapper.AdminUserMapper; +import me.gt.snaptickets.model.AdminUser; +import me.gt.snaptickets.service.AdminUserService; +import me.gt.snaptickets.util.PasswordUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class AdminUserServiceImpl implements AdminUserService { + + @Autowired + private AdminUserMapper adminUserMapper; + + @Override + public RegistrationStatus registerAdminUser(AdminUser adminUser) { + boolean isUsernameExist = adminUserMapper.getByUsername(adminUser.getUsername()) != null; + if (isUsernameExist) { + return RegistrationStatus.USERNAME_EXISTS; + } + boolean isEmailExist = adminUserMapper.getByEmail(adminUser.getEmail()) != null; + if (isEmailExist) { + return RegistrationStatus.EMAIL_EXISTS; + } + adminUser.setPassword(PasswordUtil.encryptPassword(adminUser.getPassword())); // 加密密碼 + adminUserMapper.register(adminUser); + return RegistrationStatus.SUCCESS; + } + + @Override + public AdminUser loginUser(String identifier, String password) { + AdminUser user; + if (identifier.contains("@")) { + user = adminUserMapper.getByEmail(identifier); + } else { + user = adminUserMapper.getByUsername(identifier); + } + if (user == null) { + return null; + } + if (!PasswordUtil.verifyPassword(password, user.getPassword())) { + return null; + } + return user; + } + + @Override + public boolean verifyTokenLogin(String username, String password) { + AdminUser user = adminUserMapper.getByUsername(username); + if (user == null) { + return false; + } + if (password.equals(user.getPassword())) { + return true; + } + return false; + } + + + @Override + public AdminUser getByUsername(String username) { + return adminUserMapper.getByUsername(username); + } + + @Override + public boolean updateUser(AdminUser adminUser) { + return adminUserMapper.updateUser(adminUser) > 0; + } + + @Override + public AuthStatus updatePassword(String username, String oldPassword, String newPassword) { + AdminUser user = adminUserMapper.getByUsername(username); + if (user == null) { + return AuthStatus.USER_NOT_FOUND; + } + if (!PasswordUtil.verifyPassword(oldPassword, user.getPassword())) { // 如果舊密碼不正確 + return AuthStatus.PASSWORD_INCORRECT; + } + if (oldPassword.equals(newPassword)) { // 如果新密碼與舊密碼相同 + return AuthStatus.PASSWORD_SAME; + } + adminUserMapper.updatePassword(username, PasswordUtil.encryptPassword(newPassword)); // 更新密碼 + return AuthStatus.SUCCESS; + } + + @Override + public void deleteUser(String username) { + adminUserMapper.deleteUser(username); + } +} diff --git a/src/main/java/me/gt/snaptickets/util/PasswordUtil.java b/src/main/java/me/gt/snaptickets/util/PasswordUtil.java index 5e553db..82e3a5e 100644 --- a/src/main/java/me/gt/snaptickets/util/PasswordUtil.java +++ b/src/main/java/me/gt/snaptickets/util/PasswordUtil.java @@ -2,6 +2,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import me.gt.snaptickets.model.AdminUser; import me.gt.snaptickets.model.User; import org.springframework.security.crypto.bcrypt.BCrypt; @@ -35,6 +36,23 @@ public static String generateJwtToken(User user, String jwtSecret) { return Jwts.builder() .setSubject(user.getUsername()) .claim("email", user.getEmail()) + .claim("password", user.getPassword()) + .setIssuedAt(new Date()) + .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs)) + .signWith(SignatureAlgorithm.HS256, jwtSecret) + .compact(); + } + + /** + * 生成 JWT Token + * @param adminUser + * @return JWT Token + */ + public static String generateJwtToken(AdminUser adminUser, String jwtSecret) { + return Jwts.builder() + .setSubject(adminUser.getUsername()) + .claim("email", adminUser.getEmail()) + .claim("password", adminUser.getPassword()) .setIssuedAt(new Date()) .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs)) .signWith(SignatureAlgorithm.HS256, jwtSecret) diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index f6e8a57..4dee3c3 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -38,4 +38,6 @@ payment.ecpay.order-result-url=${server.url}/payment/client spring.security.user.name=admin spring.security.user.password=123456 -jwt.secret=r3zk3yZTiSLQVsBQzww/eBN6zv78nsaoXHZUdpnpixQ= \ No newline at end of file +# JWT +jwt.user.secret=r3zk3yZTiSLQVsBQzww/eBN6zv78nsaoXHZUdpnpixQ= +jwt.admin.secret=kxzGn9G2F8NTI8ayQcI9wpSMHEnThcOrvpQAYtjNLiI= \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 084329e..69c2f22 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -35,4 +35,6 @@ payment.ecpay.order-result-url=${PROD_ECPAY_ORDER_RESULT_URL} spring.security.user.name=${PROD_SECURITY_USERNAME:admin} spring.security.user.password=${PROD_SECURITY_PASSWORD} -jwt.secret=${PROD_JWT_SECRET} \ No newline at end of file +# JWT +jwt.user.secret=${PROD_JWT_USER_SECRET} +jwt.admin.secret=${PROD_JWT_ADMIN_SECRET} \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index d7e97cc..56e9efa 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -114,3 +114,21 @@ COMMENT ON COLUMN user_ticket.issuedAt IS '獲得時間'; COMMENT ON COLUMN user_ticket.usedAt IS '使用時間'; COMMENT ON COLUMN user_ticket.expiredAt IS '使用期限'; + +-- 建立管理員資料表 +CREATE TABLE IF NOT EXISTS admins ( + username VARCHAR(20) PRIMARY KEY, + password VARCHAR NOT NULL, + name VARCHAR(20), + email VARCHAR(50), + permission VARCHAR(20), + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 設定管理員資料表的註解 +COMMENT ON COLUMN admins.username IS '帳號'; +COMMENT ON COLUMN admins.password IS '密碼'; +COMMENT ON COLUMN admins.name IS '姓名'; +COMMENT ON COLUMN admins.email IS '電子郵件'; +COMMENT ON COLUMN admins.permission IS '權限'; +COMMENT ON COLUMN admins.createdAt IS '建立時間';