Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,7 @@ DELIVERY_REQUEUE_DELAY=PT5S
CDP_INGEST_API_KEY=CHANGE_ME
# 旅程建立/查詢 API 的 Bearer 金鑰(機密,隨機產生)
CDP_ADMIN_API_KEY=CHANGE_ME
# 客服登入帳號
CDP_SUPPORT_USERNAME=support
# 客服登入密碼(機密,隨機產生)
CDP_SUPPORT_PASSWORD=CHANGE_ME
# 客服 session 閒置逾時(ISO-8601 Duration)
# 使用者 session 閒置逾時(ISO-8601 Duration)
CDP_SESSION_IDLE_TIMEOUT=PT30M
# 軌跡頁可明文顯示的屬性鍵(逗號分隔,例 cart_id,cart_total;填 - = 全部遮罩,規則見契約 §4.4)
CDP_TRACE_PLAIN_KEYS=-
Expand Down
5 changes: 5 additions & 0 deletions api/src/main/java/com/cdp/common/web/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ public enum ErrorCode {
UNAUTHENTICATED(HttpStatus.UNAUTHORIZED),
INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED),
FORBIDDEN(HttpStatus.FORBIDDEN),
CSRF_INVALID(HttpStatus.FORBIDDEN),
LOGIN_RATE_LIMITED(HttpStatus.TOO_MANY_REQUESTS),
EMAIL_TAKEN(HttpStatus.CONFLICT),
LAST_ADMIN(HttpStatus.CONFLICT),
USER_NOT_FOUND(HttpStatus.NOT_FOUND),
JOURNEY_NOT_FOUND(HttpStatus.NOT_FOUND),
ENROLLMENT_NOT_FOUND(HttpStatus.NOT_FOUND),
NOT_FOUND(HttpStatus.NOT_FOUND),
Expand Down
55 changes: 55 additions & 0 deletions api/src/main/java/com/cdp/security/AccessRule.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.cdp.security;

import java.util.Collection;
import java.util.function.Supplier;

import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.AuthorizationResult;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.access.intercept.RequestAuthorizationContext;

/**
* 角色矩陣的一列(auth.md §2):需要某角色(含 RoleHierarchy 可達的),可再限定憑證種類(僅 Bearer/僅 session)。
* 未認證一律不通過(交給 entry point 回 401)。
*/
final class AccessRule implements AuthorizationManager<RequestAuthorizationContext> {

private final RoleHierarchy hierarchy;
private final Role role;
private final Credential credential;

private AccessRule(RoleHierarchy hierarchy, Role role, Credential credential) {
this.hierarchy = hierarchy;
this.role = role;
this.credential = credential;
}

/** 任何憑證,只看角色。 */
static AccessRule role(RoleHierarchy hierarchy, Role role) {
return new AccessRule(hierarchy, role, null);
}

/** 角色且限定憑證種類。 */
static AccessRule role(RoleHierarchy hierarchy, Role role, Credential credential) {
return new AccessRule(hierarchy, role, credential);
}

@Override
public AuthorizationResult authorize(
Supplier<? extends Authentication> supplier, RequestAuthorizationContext context) {
Authentication auth = supplier.get();
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
return new AuthorizationDecision(false);
}
Collection<? extends GrantedAuthority> reachable =
hierarchy.getReachableGrantedAuthorities(auth.getAuthorities());
boolean hasRole = reachable.stream().anyMatch(a -> role.authority().equals(a.getAuthority()));
boolean hasCredential = credential == null
|| auth.getAuthorities().stream().anyMatch(a -> credential.authority().equals(a.getAuthority()));
return new AuthorizationDecision(hasRole && hasCredential);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import com.cdp.common.web.ErrorCode;

/**
* 契約 §1.1:讀 Authorization: Bearer,與 ingest/admin 金鑰常數時間比對,成功則放入 Role.INGEST/ADMIN;
* auth.md §1:讀 Authorization: Bearer,與 ingest/admin 金鑰常數時間比對,成功則放入 Role.INGEST/ADMIN;
* 帶了但不符 → 401 UNAUTHENTICATED(不再看 session)。不加 @Component:只由 SecurityConfig 掛進 security filter chain,
* 避免被 Boot 自動註冊成一般 servlet filter。
*/
Expand Down Expand Up @@ -57,7 +57,9 @@ protected void doFilterInternal(
}
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(
role.name().toLowerCase(), null, List.of(new SimpleGrantedAuthority("ROLE_" + role.name()))));
role.name().toLowerCase(), null, List.of(
new SimpleGrantedAuthority(role.authority()),
new SimpleGrantedAuthority(Credential.BEARER.authority()))));
SecurityContextHolder.setContext(context);
chain.doFilter(request, response);
}
Expand Down
121 changes: 91 additions & 30 deletions api/src/main/java/com/cdp/security/AuthController.java
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package com.cdp.security;

import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;

import org.springframework.boot.web.server.Cookie;
Expand All @@ -12,12 +17,8 @@
import org.springframework.http.MediaType;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand All @@ -27,70 +28,130 @@
import com.cdp.common.web.ApiException;
import com.cdp.common.web.ErrorCode;
import com.cdp.common.web.FieldError;
import com.cdp.security.LoginRateLimiter.Outcome;
import com.cdp.user.AppUser;
import com.cdp.user.PasswordHasher;
import com.cdp.user.UserService;

/** 契約 §1.2 客服登入。帳密比對用常數時間;登入成功換新 session id(防 session fixation)。 */
/**
* auth.md §3 登入、登出、me、改密碼。登入比對 BCrypt(帳號不存在、停用也跑一次等價成本的比對),失敗一律同一個
* 401;成功換新 session id(防 session fixation),session 只存 user_id。限速在密碼比對之前檢查,鎖定中不跑 BCrypt。
*/
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {

private final AuthProperties properties;
private final Cookie cookieConfig;
private static final int EMAIL_MAX = 254;
private static final String INVALID_CREDENTIALS_DETAIL = "帳號或密碼錯誤";

public AuthController(AuthProperties properties, ServerProperties serverProperties) {
this.properties = properties;
this.cookieConfig = serverProperties.getServlet().getSession().getCookie();
private final UserService users;
private final PasswordHasher hasher;
private final LoginRateLimiter limiter;
private final SessionCsrfTokenRepository csrf;
private final Cookie sessionCookie;

public AuthController(
UserService users,
PasswordHasher hasher,
LoginRateLimiter limiter,
SessionCsrfTokenRepository csrf,
ServerProperties serverProperties) {
this.users = users;
this.hasher = hasher;
this.limiter = limiter;
this.csrf = csrf;
this.sessionCookie = serverProperties.getServlet().getSession().getCookie();
}

@PostMapping(path = "/login", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> login(@RequestBody LoginRequest body, HttpServletRequest request) {
public ResponseEntity<Void> login(
@RequestBody LoginRequest body, HttpServletRequest request, HttpServletResponse response)
throws IOException {
List<FieldError> errors = new ArrayList<>();
if (body.username() == null || body.username().isBlank()) {
errors.add(new FieldError("username", "必填"));
if (body.email() == null || body.email().isBlank()) {
errors.add(new FieldError("email", "必填"));
} else if (body.email().length() > EMAIL_MAX) {
// 不可能是真實帳號;不進限速表,免得被超長字串佔記憶體
errors.add(new FieldError("email", "長度不可超過 " + EMAIL_MAX + " 字元"));
}
if (body.password() == null || body.password().isBlank()) {
if (body.password() == null || body.password().isEmpty()) {
errors.add(new FieldError("password", "必填"));
}
if (!errors.isEmpty()) {
throw new ApiException(ErrorCode.VALIDATION_FAILED, "請求內容有誤", errors);
}
// 兩項都比、不短路:帳號錯與密碼錯耗時相同、回應相同
boolean userOk = SecretMatcher.matches(body.username(), properties.supportUsername());
boolean passOk = SecretMatcher.matches(body.password(), properties.supportPassword());
if (!(userOk & passOk)) {
throw new ApiException(ErrorCode.INVALID_CREDENTIALS, "帳號或密碼錯誤");
String key = body.email().trim().toLowerCase(Locale.ROOT);

// 檢查鎖定與佔用名額是原子的:並行請求不能同時越過門檻
Optional<Duration> locked = limiter.tryBegin(key);
if (locked.isPresent()) {
response.setHeader(
HttpHeaders.RETRY_AFTER, Long.toString(LoginRateLimiter.retryAfterSeconds(locked.get())));
SecurityProblemWriter.write(response, ErrorCode.LOGIN_RATE_LIMITED, "登入嘗試過於頻繁,請稍後再試。");
return null;
}
AppUser user = null;
Outcome outcome = Outcome.ABORTED;
try {
user = users.findByEmail(key).orElse(null);
boolean passwordOk = user != null
? hasher.matches(body.password(), user.passwordHash())
: hasher.matchesDummy(body.password());
outcome = user != null && user.active() && passwordOk ? Outcome.SUCCESS : Outcome.FAILURE;
} finally {
limiter.finish(key, outcome);
}
if (outcome != Outcome.SUCCESS) {
throw new ApiException(ErrorCode.INVALID_CREDENTIALS, INVALID_CREDENTIALS_DETAIL);
}

HttpSession old = request.getSession(false);
if (old != null) {
old.invalidate();
}
HttpSession session = request.getSession(true);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(
properties.supportUsername(), null,
List.of(new SimpleGrantedAuthority("ROLE_" + Role.SUPPORT.name()))));
session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
session.setAttribute(SessionAuthenticationFilter.USER_ID_ATTRIBUTE, user.userId());
csrf.rotate(request, response);
return ResponseEntity.noContent().build();
}

@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest request) {
public ResponseEntity<Void> logout(HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
ResponseCookie cleared = ResponseCookie.from(cookieConfig.getName() != null ? cookieConfig.getName() : "JSESSIONID", "")
.path(cookieConfig.getPath() != null ? cookieConfig.getPath() : "/")
csrf.clear(response);
ResponseCookie cleared = ResponseCookie.from(
sessionCookie.getName() != null ? sessionCookie.getName() : "JSESSIONID", "")
.path(sessionCookie.getPath() != null ? sessionCookie.getPath() : "/")
.maxAge(0)
.httpOnly(true)
.secure(true)
.secure(sessionCookie.getSecure() == null || sessionCookie.getSecure())
.sameSite("Strict")
.build();
return ResponseEntity.noContent().header(HttpHeaders.SET_COOKIE, cleared.toString()).build();
}

/** 回應一定帶新的 XSRF-TOKEN(登入成功後前端先呼叫這支取值)。 */
@GetMapping("/me")
public CurrentUserResponse me() {
public CurrentUserResponse me(HttpServletRequest request, HttpServletResponse response) {
SessionUser user = currentUser();
csrf.rotate(request, response);
return new CurrentUserResponse(user.userId(), user.email(), user.displayName(), user.role());
}

@PostMapping(path = "/password", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> changePassword(@RequestBody PasswordChangeRequest body) {
users.changePassword(currentUser().userId(), body.currentPassword(), body.newPassword());
return ResponseEntity.noContent().build();
}

private static SessionUser currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return new CurrentUserResponse(auth.getName());
if (auth != null && auth.getPrincipal() instanceof SessionUser user) {
return user;
}
throw new ApiException(ErrorCode.UNAUTHENTICATED, "缺少有效的憑證");
}
}
10 changes: 3 additions & 7 deletions api/src/main/java/com/cdp/security/AuthProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,14 @@
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* cdp.security.*:金鑰與客服帳密(變數見 DESIGN-01「設定」);全部只從環境變數來,不得寫預設值;session 逾時在
* cdp.security.*:攝取金鑰與管理金鑰(auth.md §1);只從環境變數來,不得寫預設值;session 逾時在
* server.servlet.session.timeout。toString 不輸出機密;啟動驗證見 {@link AuthPropertiesValidator}。
*/
@ConfigurationProperties("cdp.security")
public record AuthProperties(
String ingestApiKey,
String adminApiKey,
String supportUsername,
String supportPassword) {
public record AuthProperties(String ingestApiKey, String adminApiKey) {

@Override
public String toString() {
return "AuthProperties[ingestApiKey=***, adminApiKey=***, supportUsername=***, supportPassword=***]";
return "AuthProperties[ingestApiKey=***, adminApiKey=***]";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Component;

/** 啟動時驗證 cdp.security.*:金鑰與客服帳密不得空白、兩把金鑰不得相同。訊息只講設定名稱,不含值。 */
/** 啟動時驗證 cdp.security.*:金鑰不得空白、兩把金鑰不得相同。訊息只講設定名稱,不含值。 */
@Component
class AuthPropertiesValidator implements InitializingBean {

Expand All @@ -17,8 +17,6 @@ class AuthPropertiesValidator implements InitializingBean {
public void afterPropertiesSet() {
require(properties.ingestApiKey(), "cdp.security.ingest-api-key(CDP_INGEST_API_KEY)");
require(properties.adminApiKey(), "cdp.security.admin-api-key(CDP_ADMIN_API_KEY)");
require(properties.supportUsername(), "cdp.security.support-username(客服帳號)");
require(properties.supportPassword(), "cdp.security.support-password(客服密碼)");
if (properties.ingestApiKey().equals(properties.adminApiKey())) {
throw new IllegalStateException(
"cdp.security.ingest-api-key 與 cdp.security.admin-api-key 不可相同(兩把金鑰必須不同)");
Expand Down
13 changes: 13 additions & 0 deletions api/src/main/java/com/cdp/security/Credential.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.cdp.security;

/** 憑證種類,以額外的 authority 附在 Authentication 上,讓角色矩陣能區分「僅管理金鑰」「僅 session」。 */
public enum Credential {
/** Authorization: Bearer(攝取金鑰或管理金鑰)。 */
BEARER,
/** cookie CDP_SESSION 對應的使用者。 */
SESSION;

public String authority() {
return "CRED_" + name();
}
}
6 changes: 4 additions & 2 deletions api/src/main/java/com/cdp/security/CurrentUserResponse.java
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
package com.cdp.security;

/** 契約 §1.2 GET /api/v1/auth/me。 */
public record CurrentUserResponse(String username) {}
import com.cdp.user.UserRole;

/** auth.md §3 GET /api/v1/auth/me 的 CurrentUser。 */
public record CurrentUserResponse(String userId, String email, String displayName, UserRole role) {}
9 changes: 9 additions & 0 deletions api/src/main/java/com/cdp/security/LoginProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.cdp.security;

import java.time.Duration;

import org.springframework.boot.context.properties.ConfigurationProperties;

/** cdp.login.*:登入限速(D-045,auth.md §3)。CDP_LOGIN_MAX_FAILURES、CDP_LOGIN_LOCKOUT;驗證見 {@link LoginRateLimiter}。 */
@ConfigurationProperties("cdp.login")
public record LoginProperties(int maxFailures, Duration lockout) {}
Loading
Loading