From 69dde07fc9924862c5d28fe1d70a265458fb792d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=BF=97=E6=81=92?= <842328916@qq.com> Date: Fri, 19 Sep 2025 00:53:10 +0800 Subject: [PATCH] =?UTF-8?q?oauth-=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- accompany-oauth/accompany-oauth-sdk/pom.xml | 35 +++ .../accompany/oauth/constant/AuthType.java | 59 +++++ .../oauth/constant/OAuthConstants.java | 84 +++++++ .../accompany/oauth/constant/UserStatus.java | 49 ++++ .../accompany/oauth/dto/AuthCredentials.java | 111 ++++++++++ .../com/accompany/oauth/dto/AuthResult.java | 154 +++++++++++++ .../com/accompany/oauth/dto/DeviceInfo.java | 122 ++++++++++ .../accompany/oauth/dto/H5AccessToken.java | 120 ++++++++++ .../com/accompany/oauth/dto/TokenRequest.java | 145 ++++++++++++ .../com/accompany/oauth/dto/UserInfo.java | 136 ++++++++++++ .../exception/AuthenticationException.java | 36 +++ .../oauth/exception/OAuthException.java | 33 +++ .../oauth/exception/TokenException.java | 36 +++ .../com/accompany/oauth/model/TokenPair.java | 95 ++++++++ .../oauth/model/TokenValidation.java | 125 +++++++++++ .../accompany/oauth/model/UserDetails.java | 135 +++++++++++ .../accompany-oauth-service/pom.xml | 60 +++++ .../accompany/oauth/manager/TokenManager.java | 209 ++++++++++++++++++ .../oauth/service/AuthenticationService.java | 202 +++++++++++++++++ .../accompany/oauth/service/UserService.java | 167 ++++++++++++++ .../com/accompany/oauth/util/JwtUtil.java | 190 ++++++++++++++++ accompany-oauth/accompany-oauth-web/pom.xml | 45 ++++ .../com/accompany/oauth/OAuthApplication.java | 42 ++++ .../config/AuthResultJsonSerializer.java | 63 ++++++ .../oauth/config/GlobalExceptionHandler.java | 122 ++++++++++ .../com/accompany/oauth/config/WebConfig.java | 51 +++++ .../oauth/controller/AccountController.java | 130 +++++++++++ .../oauth/controller/OAuthController.java | 185 ++++++++++++++++ .../AuthenticationInterceptor.java | 117 ++++++++++ .../src/main/resources/application-dev.yaml | 28 +++ .../src/main/resources/application-prod.yaml | 30 +++ .../src/main/resources/application.yaml | 75 +++++++ pom.xml | 1 + 33 files changed, 3192 insertions(+) create mode 100644 accompany-oauth/accompany-oauth-sdk/pom.xml create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/constant/AuthType.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/constant/OAuthConstants.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/constant/UserStatus.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/AuthCredentials.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/AuthResult.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/DeviceInfo.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/H5AccessToken.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/TokenRequest.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/UserInfo.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/exception/AuthenticationException.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/exception/OAuthException.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/exception/TokenException.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/model/TokenPair.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/model/TokenValidation.java create mode 100644 accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/model/UserDetails.java create mode 100644 accompany-oauth/accompany-oauth-service/pom.xml create mode 100644 accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/manager/TokenManager.java create mode 100644 accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/service/AuthenticationService.java create mode 100644 accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/service/UserService.java create mode 100644 accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/util/JwtUtil.java create mode 100644 accompany-oauth/accompany-oauth-web/pom.xml create mode 100644 accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/OAuthApplication.java create mode 100644 accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/config/AuthResultJsonSerializer.java create mode 100644 accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/config/GlobalExceptionHandler.java create mode 100644 accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/config/WebConfig.java create mode 100644 accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/controller/AccountController.java create mode 100644 accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/controller/OAuthController.java create mode 100644 accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/interceptor/AuthenticationInterceptor.java create mode 100644 accompany-oauth/accompany-oauth-web/src/main/resources/application-dev.yaml create mode 100644 accompany-oauth/accompany-oauth-web/src/main/resources/application-prod.yaml create mode 100644 accompany-oauth/accompany-oauth-web/src/main/resources/application.yaml diff --git a/accompany-oauth/accompany-oauth-sdk/pom.xml b/accompany-oauth/accompany-oauth-sdk/pom.xml new file mode 100644 index 000000000..1387d96df --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + com.accompany + accompany-oauth + 1.0.0 + + + accompany-oauth-sdk + jar + OAuth SDK模块 - 数据传输对象和接口定义 + + + + com.accompany + accompany-core + ${revision} + + + com.accompany + accompany-basic-sdk + ${revision} + + + + io.jsonwebtoken + jjwt + + + + + \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/constant/AuthType.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/constant/AuthType.java new file mode 100644 index 000000000..9ef1bc855 --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/constant/AuthType.java @@ -0,0 +1,59 @@ +package com.accompany.oauth.constant; + +/** + * 认证类型枚举 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public enum AuthType { + /** + * 密码登录 + */ + PASSWORD("password", "密码登录"), + + /** + * 验证码登录 + */ + VERIFY_CODE("verify_code", "验证码登录"), + + /** + * 邮箱登录 + */ + EMAIL("email", "邮箱登录"), + + /** + * OpenID登录 + */ + OPENID("openid", "OpenID登录"), + + /** + * Apple登录 + */ + APPLE("apple", "Apple登录"); + + private final String code; + private final String description; + + AuthType(String code, String description) { + this.code = code; + this.description = description; + } + + public String getCode() { + return code; + } + + public String getDescription() { + return description; + } + + public static AuthType fromCode(String code) { + for (AuthType type : values()) { + if (type.code.equals(code)) { + return type; + } + } + throw new IllegalArgumentException("未知的认证类型: " + code); + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/constant/OAuthConstants.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/constant/OAuthConstants.java new file mode 100644 index 000000000..8ea6eb1bf --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/constant/OAuthConstants.java @@ -0,0 +1,84 @@ +package com.accompany.oauth.constant; + +/** + * OAuth常量类 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public final class OAuthConstants { + + /** + * Token相关常量 + */ + public static final class Token { + public static final String BEARER_PREFIX = "Bearer "; + public static final String ACCESS_TOKEN = "access_token"; + public static final String REFRESH_TOKEN = "refresh_token"; + public static final String TOKEN_TYPE = "token_type"; + public static final String EXPIRES_IN = "expires_in"; + public static final String SCOPE = "scope"; + + // Redis Key前缀 + public static final String ACCESS_TOKEN_PREFIX = "oauth:access_token:"; + public static final String REFRESH_TOKEN_PREFIX = "oauth:refresh_token:"; + public static final String USER_TOKEN_PREFIX = "oauth:user_token:"; + + private Token() {} + } + + /** + * 授权类型常量 + */ + public static final class GrantType { + public static final String PASSWORD = "password"; + public static final String VERIFY_CODE = "verify_code"; + public static final String EMAIL = "email"; + public static final String OPENID = "openid"; + public static final String REFRESH_TOKEN = "refresh_token"; + + private GrantType() {} + } + + /** + * HTTP头常量 + */ + public static final class Headers { + public static final String AUTHORIZATION = "Authorization"; + public static final String CLIENT_ID = "Client-Id"; + public static final String DEVICE_ID = "Device-Id"; + public static final String USER_AGENT = "User-Agent"; + + private Headers() {} + } + + /** + * 权限范围常量 + */ + public static final class Scope { + public static final String READ = "read"; + public static final String WRITE = "write"; + public static final String ALL = "read write"; + + private Scope() {} + } + + /** + * 错误码常量 + */ + public static final class ErrorCode { + public static final String INVALID_REQUEST = "invalid_request"; + public static final String INVALID_CLIENT = "invalid_client"; + public static final String INVALID_GRANT = "invalid_grant"; + public static final String UNAUTHORIZED_CLIENT = "unauthorized_client"; + public static final String UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type"; + public static final String INVALID_SCOPE = "invalid_scope"; + public static final String ACCESS_DENIED = "access_denied"; + public static final String INVALID_TOKEN = "invalid_token"; + public static final String TOKEN_EXPIRED = "token_expired"; + + private ErrorCode() {} + } + + private OAuthConstants() {} +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/constant/UserStatus.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/constant/UserStatus.java new file mode 100644 index 000000000..23d13accc --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/constant/UserStatus.java @@ -0,0 +1,49 @@ +package com.accompany.oauth.constant; + +/** + * 用户状态枚举 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public enum UserStatus { + /** + * 正常状态 + */ + NORMAL(1, "正常"), + + /** + * 已冻结 + */ + FROZEN(2, "已冻结"), + + /** + * 已删除 + */ + DELETED(3, "已删除"); + + private final int code; + private final String description; + + UserStatus(int code, String description) { + this.code = code; + this.description = description; + } + + public int getCode() { + return code; + } + + public String getDescription() { + return description; + } + + public static UserStatus fromCode(int code) { + for (UserStatus status : values()) { + if (status.code == code) { + return status; + } + } + throw new IllegalArgumentException("未知的用户状态: " + code); + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/AuthCredentials.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/AuthCredentials.java new file mode 100644 index 000000000..3cbafb3a4 --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/AuthCredentials.java @@ -0,0 +1,111 @@ +package com.accompany.oauth.dto; + +import com.accompany.oauth.constant.AuthType; + +/** + * 认证凭据DTO + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public class AuthCredentials { + + /** + * 认证类型 + */ + private AuthType type; + + /** + * 主体标识(手机号/邮箱/OpenID等) + */ + private String principal; + + /** + * 凭据(密码/验证码等) + */ + private String credentials; + + /** + * 客户端ID + */ + private String clientId; + + /** + * 设备信息 + */ + private DeviceInfo deviceInfo; + + /** + * 权限范围 + */ + private String scope; + + public AuthCredentials() { + } + + public AuthCredentials(AuthType type, String principal, String credentials) { + this.type = type; + this.principal = principal; + this.credentials = credentials; + } + + public AuthType getType() { + return type; + } + + public void setType(AuthType type) { + this.type = type; + } + + public String getPrincipal() { + return principal; + } + + public void setPrincipal(String principal) { + this.principal = principal; + } + + public String getCredentials() { + return credentials; + } + + public void setCredentials(String credentials) { + this.credentials = credentials; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public DeviceInfo getDeviceInfo() { + return deviceInfo; + } + + public void setDeviceInfo(DeviceInfo deviceInfo) { + this.deviceInfo = deviceInfo; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + @Override + public String toString() { + return "AuthCredentials{" + + "type=" + type + + ", principal='" + principal + '\'' + + ", credentials='[PROTECTED]'" + + ", clientId='" + clientId + '\'' + + ", deviceInfo=" + deviceInfo + + ", scope='" + scope + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/AuthResult.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/AuthResult.java new file mode 100644 index 000000000..854ae6e9e --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/AuthResult.java @@ -0,0 +1,154 @@ +package com.accompany.oauth.dto; + +import com.accompany.oauth.model.UserDetails; + +/** + * 认证结果DTO + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public class AuthResult { + + /** + * 访问令牌 + */ + private String accessToken; + + /** + * 刷新令牌 + */ + private String refreshToken; + + /** + * 过期时间(秒) + */ + private Long expiresIn; + + /** + * 令牌类型 + */ + private String tokenType = "Bearer"; + + /** + * 权限范围 + */ + private String scope; + + /** + * 用户ID (兼容oauth2) + */ + private Long uid; + + /** + * 网易云信Token (兼容oauth2) + */ + private String netEaseToken = ""; + + /** + * 网易云信账号ID (兼容oauth2) + */ + private String accid = ""; + + /** + * 用户信息 + */ + private UserInfo userInfo; + + public AuthResult() { + } + + public AuthResult(String accessToken, String refreshToken, Long expiresIn, UserInfo userInfo) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.expiresIn = expiresIn; + this.userInfo = userInfo; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public Long getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(Long expiresIn) { + this.expiresIn = expiresIn; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public Long getUid() { + return uid; + } + + public void setUid(Long uid) { + this.uid = uid; + } + + public String getNetEaseToken() { + return netEaseToken; + } + + public void setNetEaseToken(String netEaseToken) { + this.netEaseToken = netEaseToken; + } + + public String getAccid() { + return accid; + } + + public void setAccid(String accid) { + this.accid = accid; + } + + public UserInfo getUserInfo() { + return userInfo; + } + + public void setUserInfo(UserInfo userInfo) { + this.userInfo = userInfo; + } + + @Override + public String toString() { + return "AuthResult{" + + "accessToken='" + accessToken + '\'' + + ", refreshToken='" + refreshToken + '\'' + + ", expiresIn=" + expiresIn + + ", tokenType='" + tokenType + '\'' + + ", scope='" + scope + '\'' + + ", uid=" + uid + + ", netEaseToken='" + netEaseToken + '\'' + + ", accid='" + accid + '\'' + + ", userInfo=" + userInfo + + '}'; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/DeviceInfo.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/DeviceInfo.java new file mode 100644 index 000000000..5a618a7c2 --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/DeviceInfo.java @@ -0,0 +1,122 @@ +package com.accompany.oauth.dto; + +/** + * 设备信息DTO + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public class DeviceInfo { + + /** + * 设备ID + */ + private String deviceId; + + /** + * 设备类型(iOS/Android/Web等) + */ + private String deviceType; + + /** + * 设备型号 + */ + private String deviceModel; + + /** + * 操作系统版本 + */ + private String osVersion; + + /** + * 应用版本 + */ + private String appVersion; + + /** + * IP地址 + */ + private String ipAddress; + + /** + * User-Agent + */ + private String userAgent; + + public DeviceInfo() { + } + + public DeviceInfo(String deviceId, String deviceType) { + this.deviceId = deviceId; + this.deviceType = deviceType; + } + + public String getDeviceId() { + return deviceId; + } + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + public String getDeviceType() { + return deviceType; + } + + public void setDeviceType(String deviceType) { + this.deviceType = deviceType; + } + + public String getDeviceModel() { + return deviceModel; + } + + public void setDeviceModel(String deviceModel) { + this.deviceModel = deviceModel; + } + + public String getOsVersion() { + return osVersion; + } + + public void setOsVersion(String osVersion) { + this.osVersion = osVersion; + } + + public String getAppVersion() { + return appVersion; + } + + public void setAppVersion(String appVersion) { + this.appVersion = appVersion; + } + + public String getIpAddress() { + return ipAddress; + } + + public void setIpAddress(String ipAddress) { + this.ipAddress = ipAddress; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } + + @Override + public String toString() { + return "DeviceInfo{" + + "deviceId='" + deviceId + '\'' + + ", deviceType='" + deviceType + '\'' + + ", deviceModel='" + deviceModel + '\'' + + ", osVersion='" + osVersion + '\'' + + ", appVersion='" + appVersion + '\'' + + ", ipAddress='" + ipAddress + '\'' + + ", userAgent='" + userAgent + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/H5AccessToken.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/H5AccessToken.java new file mode 100644 index 000000000..7b6aad212 --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/H5AccessToken.java @@ -0,0 +1,120 @@ +package com.accompany.oauth.dto; + +/** + * H5访问令牌DTO (兼容OAuth2格式) + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public class H5AccessToken { + + /** + * 用户ID + */ + private Long uid; + + /** + * 访问令牌 + */ + private String access_token; + + /** + * 过期时间(秒) + */ + private Long expires_in; + + /** + * 令牌类型 + */ + private String token_type = "Bearer"; + + /** + * 刷新令牌 + */ + private String refresh_token; + + /** + * 权限范围 + */ + private String scope; + + public H5AccessToken() { + } + + public H5AccessToken(Long uid, String accessToken, Long expiresIn) { + this.uid = uid; + this.access_token = accessToken; + this.expires_in = expiresIn; + } + + public static H5AccessToken fromAuthResult(AuthResult authResult) { + H5AccessToken h5Token = new H5AccessToken(); + h5Token.setUid(authResult.getUid()); + h5Token.setAccess_token(authResult.getAccessToken()); + h5Token.setRefresh_token(authResult.getRefreshToken()); + h5Token.setExpires_in(authResult.getExpiresIn()); + h5Token.setToken_type(authResult.getTokenType()); + h5Token.setScope(authResult.getScope()); + return h5Token; + } + + public Long getUid() { + return uid; + } + + public void setUid(Long uid) { + this.uid = uid; + } + + public String getAccess_token() { + return access_token; + } + + public void setAccess_token(String access_token) { + this.access_token = access_token; + } + + public Long getExpires_in() { + return expires_in; + } + + public void setExpires_in(Long expires_in) { + this.expires_in = expires_in; + } + + public String getToken_type() { + return token_type; + } + + public void setToken_type(String token_type) { + this.token_type = token_type; + } + + public String getRefresh_token() { + return refresh_token; + } + + public void setRefresh_token(String refresh_token) { + this.refresh_token = refresh_token; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + @Override + public String toString() { + return "H5AccessToken{" + + "uid=" + uid + + ", access_token='" + access_token + '\'' + + ", expires_in=" + expires_in + + ", token_type='" + token_type + '\'' + + ", refresh_token='" + refresh_token + '\'' + + ", scope='" + scope + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/TokenRequest.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/TokenRequest.java new file mode 100644 index 000000000..979d89773 --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/TokenRequest.java @@ -0,0 +1,145 @@ +package com.accompany.oauth.dto; + +/** + * OAuth Token请求DTO + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public class TokenRequest { + + /** + * 授权类型 (password, verify_code, email, openid, refresh_token) + */ + private String grantType; + + /** + * 用户名/手机号/邮箱/OpenID + */ + private String username; + + /** + * 密码/验证码 + */ + private String password; + + /** + * 客户端ID + */ + private String clientId; + + /** + * 客户端密钥 + */ + private String clientSecret; + + /** + * 权限范围 + */ + private String scope; + + /** + * 刷新令牌(当grant_type为refresh_token时使用) + */ + private String refreshToken; + + /** + * 第三方登录类型(Apple/微信等) + */ + private String thirdType; + + /** + * 设备ID + */ + private String deviceId; + + public TokenRequest() { + } + + public String getGrantType() { + return grantType; + } + + public void setGrantType(String grantType) { + this.grantType = grantType; + } + + 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 getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getThirdType() { + return thirdType; + } + + public void setThirdType(String thirdType) { + this.thirdType = thirdType; + } + + public String getDeviceId() { + return deviceId; + } + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + @Override + public String toString() { + return "TokenRequest{" + + "grantType='" + grantType + '\'' + + ", username='" + username + '\'' + + ", password='[PROTECTED]'" + + ", clientId='" + clientId + '\'' + + ", clientSecret='[PROTECTED]'" + + ", scope='" + scope + '\'' + + ", refreshToken='[PROTECTED]'" + + ", thirdType='" + thirdType + '\'' + + ", deviceId='" + deviceId + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/UserInfo.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/UserInfo.java new file mode 100644 index 000000000..a8ab944b9 --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/dto/UserInfo.java @@ -0,0 +1,136 @@ +package com.accompany.oauth.dto; + +/** + * 用户信息DTO + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public class UserInfo { + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户名 + */ + private String username; + + /** + * 手机号(脱敏) + */ + private String phone; + + /** + * 邮箱(脱敏) + */ + private String email; + + /** + * 头像URL + */ + private String avatar; + + /** + * 昵称 + */ + private String nickname; + + /** + * 性别(1-男,2-女,0-未知) + */ + private Integer gender; + + /** + * 用户状态 + */ + private Integer status; + + public UserInfo() { + } + + public UserInfo(Long userId, String username) { + this.userId = userId; + this.username = username; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getAvatar() { + return avatar; + } + + public void setAvatar(String avatar) { + this.avatar = avatar; + } + + public String getNickname() { + return nickname; + } + + public void setNickname(String nickname) { + this.nickname = nickname; + } + + public Integer getGender() { + return gender; + } + + public void setGender(Integer gender) { + this.gender = gender; + } + + public Integer getStatus() { + return status; + } + + public void setStatus(Integer status) { + this.status = status; + } + + @Override + public String toString() { + return "UserInfo{" + + "userId=" + userId + + ", username='" + username + '\'' + + ", phone='" + phone + '\'' + + ", email='" + email + '\'' + + ", avatar='" + avatar + '\'' + + ", nickname='" + nickname + '\'' + + ", gender=" + gender + + ", status=" + status + + '}'; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/exception/AuthenticationException.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/exception/AuthenticationException.java new file mode 100644 index 000000000..079fcd6c2 --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/exception/AuthenticationException.java @@ -0,0 +1,36 @@ +package com.accompany.oauth.exception; + +import com.accompany.oauth.constant.OAuthConstants; + +/** + * 认证异常 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public class AuthenticationException extends OAuthException { + + public AuthenticationException(String errorDescription) { + super(OAuthConstants.ErrorCode.ACCESS_DENIED, errorDescription); + } + + public AuthenticationException(String errorDescription, Throwable cause) { + super(OAuthConstants.ErrorCode.ACCESS_DENIED, errorDescription, cause); + } + + public static AuthenticationException invalidCredentials() { + return new AuthenticationException("用户名或密码错误"); + } + + public static AuthenticationException userNotFound() { + return new AuthenticationException("用户不存在"); + } + + public static AuthenticationException userFrozen() { + return new AuthenticationException("用户已被冻结"); + } + + public static AuthenticationException invalidVerifyCode() { + return new AuthenticationException("验证码错误或已过期"); + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/exception/OAuthException.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/exception/OAuthException.java new file mode 100644 index 000000000..62180395b --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/exception/OAuthException.java @@ -0,0 +1,33 @@ +package com.accompany.oauth.exception; + +/** + * OAuth认证异常基类 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public class OAuthException extends RuntimeException { + + private final String errorCode; + private final String errorDescription; + + public OAuthException(String errorCode, String errorDescription) { + super(errorDescription); + this.errorCode = errorCode; + this.errorDescription = errorDescription; + } + + public OAuthException(String errorCode, String errorDescription, Throwable cause) { + super(errorDescription, cause); + this.errorCode = errorCode; + this.errorDescription = errorDescription; + } + + public String getErrorCode() { + return errorCode; + } + + public String getErrorDescription() { + return errorDescription; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/exception/TokenException.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/exception/TokenException.java new file mode 100644 index 000000000..ac9b3900d --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/exception/TokenException.java @@ -0,0 +1,36 @@ +package com.accompany.oauth.exception; + +import com.accompany.oauth.constant.OAuthConstants; + +/** + * Token异常 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public class TokenException extends OAuthException { + + public TokenException(String errorCode, String errorDescription) { + super(errorCode, errorDescription); + } + + public TokenException(String errorCode, String errorDescription, Throwable cause) { + super(errorCode, errorDescription, cause); + } + + public static TokenException invalidToken() { + return new TokenException(OAuthConstants.ErrorCode.INVALID_TOKEN, "无效的Token"); + } + + public static TokenException tokenExpired() { + return new TokenException(OAuthConstants.ErrorCode.TOKEN_EXPIRED, "Token已过期"); + } + + public static TokenException invalidRefreshToken() { + return new TokenException(OAuthConstants.ErrorCode.INVALID_GRANT, "无效的刷新Token"); + } + + public static TokenException tokenGenerationFailed() { + return new TokenException(OAuthConstants.ErrorCode.INVALID_REQUEST, "Token生成失败"); + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/model/TokenPair.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/model/TokenPair.java new file mode 100644 index 000000000..dbb607623 --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/model/TokenPair.java @@ -0,0 +1,95 @@ +package com.accompany.oauth.model; + +/** + * Token对模型 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public class TokenPair { + + /** + * 访问令牌 + */ + private String accessToken; + + /** + * 刷新令牌 + */ + private String refreshToken; + + /** + * 过期时间(秒) + */ + private Long expiresIn; + + /** + * 令牌类型 + */ + private String tokenType = "Bearer"; + + /** + * 权限范围 + */ + private String scope; + + public TokenPair() { + } + + public TokenPair(String accessToken, String refreshToken, Long expiresIn) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.expiresIn = expiresIn; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public Long getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(Long expiresIn) { + this.expiresIn = expiresIn; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } + + @Override + public String toString() { + return "TokenPair{" + + "accessToken='" + accessToken + '\'' + + ", refreshToken='" + refreshToken + '\'' + + ", expiresIn=" + expiresIn + + ", tokenType='" + tokenType + '\'' + + ", scope='" + scope + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/model/TokenValidation.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/model/TokenValidation.java new file mode 100644 index 000000000..7d3bfbf5f --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/model/TokenValidation.java @@ -0,0 +1,125 @@ +package com.accompany.oauth.model; + +import java.util.Date; +import java.util.Set; + +/** + * Token验证结果模型 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public class TokenValidation { + + /** + * 是否有效 + */ + private boolean valid; + + /** + * 用户ID + */ + private Long userId; + + /** + * 权限范围 + */ + private Set scopes; + + /** + * 过期时间 + */ + private Date expirationTime; + + /** + * 客户端ID + */ + private String clientId; + + /** + * 错误信息 + */ + private String errorMessage; + + public TokenValidation() { + } + + public TokenValidation(boolean valid) { + this.valid = valid; + } + + public static TokenValidation valid(Long userId, Set scopes, Date expirationTime, String clientId) { + TokenValidation validation = new TokenValidation(true); + validation.setUserId(userId); + validation.setScopes(scopes); + validation.setExpirationTime(expirationTime); + validation.setClientId(clientId); + return validation; + } + + public static TokenValidation invalid(String errorMessage) { + TokenValidation validation = new TokenValidation(false); + validation.setErrorMessage(errorMessage); + return validation; + } + + public boolean isValid() { + return valid; + } + + public void setValid(boolean valid) { + this.valid = valid; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Set getScopes() { + return scopes; + } + + public void setScopes(Set scopes) { + this.scopes = scopes; + } + + public Date getExpirationTime() { + return expirationTime; + } + + public void setExpirationTime(Date expirationTime) { + this.expirationTime = expirationTime; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + public String toString() { + return "TokenValidation{" + + "valid=" + valid + + ", userId=" + userId + + ", scopes=" + scopes + + ", expirationTime=" + expirationTime + + ", clientId='" + clientId + '\'' + + ", errorMessage='" + errorMessage + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/model/UserDetails.java b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/model/UserDetails.java new file mode 100644 index 000000000..2cf488875 --- /dev/null +++ b/accompany-oauth/accompany-oauth-sdk/src/main/java/com/accompany/oauth/model/UserDetails.java @@ -0,0 +1,135 @@ +package com.accompany.oauth.model; + +import com.accompany.oauth.constant.UserStatus; + +import java.util.Set; + +/** + * 用户详情模型 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +public class UserDetails { + + /** + * 用户ID + */ + private Long userId; + + /** + * 手机号 + */ + private String phone; + + /** + * 邮箱 + */ + private String email; + + /** + * 用户名 + */ + private String username; + + /** + * 用户状态 + */ + private UserStatus status; + + /** + * 权限集合 + */ + private Set authorities; + + /** + * 客户端ID + */ + private String clientId; + + public UserDetails() { + } + + public UserDetails(Long userId, String phone, String email, UserStatus status) { + this.userId = userId; + this.phone = phone; + this.email = email; + this.status = status; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public UserStatus getStatus() { + return status; + } + + public void setStatus(UserStatus status) { + this.status = status; + } + + public Set getAuthorities() { + return authorities; + } + + public void setAuthorities(Set authorities) { + this.authorities = authorities; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + /** + * 检查用户是否可用 + */ + public boolean isEnabled() { + return status == UserStatus.NORMAL; + } + + @Override + public String toString() { + return "UserDetails{" + + "userId=" + userId + + ", phone='" + phone + '\'' + + ", email='" + email + '\'' + + ", username='" + username + '\'' + + ", status=" + status + + ", authorities=" + authorities + + ", clientId='" + clientId + '\'' + + '}'; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-service/pom.xml b/accompany-oauth/accompany-oauth-service/pom.xml new file mode 100644 index 000000000..391cff283 --- /dev/null +++ b/accompany-oauth/accompany-oauth-service/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + com.accompany + accompany-oauth + 1.0.0 + + + accompany-oauth-service + jar + OAuth Service模块 - 业务逻辑实现 + + + + com.accompany + accompany-core + ${revision} + + + com.accompany + accompany-oauth-sdk + ${revision} + + + com.accompany + accompany-basic-service + ${revision} + + + com.accompany + accompany-sms-service + ${revision} + + + com.accompany + accompany-email-service + ${revision} + + + + org.redisson + redisson-spring-boot-starter + + + + com.googlecode.libphonenumber + libphonenumber + + + + org.springframework.boot + spring-boot-starter-web + + + + + \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/manager/TokenManager.java b/accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/manager/TokenManager.java new file mode 100644 index 000000000..dad45bff7 --- /dev/null +++ b/accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/manager/TokenManager.java @@ -0,0 +1,209 @@ +package com.accompany.oauth.manager; + +import com.accompany.oauth.constant.OAuthConstants; +import com.accompany.oauth.dto.UserInfo; +import com.accompany.oauth.exception.TokenException; +import com.accompany.oauth.model.TokenPair; +import com.accompany.oauth.model.TokenValidation; +import com.accompany.oauth.model.UserDetails; +import com.accompany.oauth.util.JwtUtil; +import io.jsonwebtoken.Claims; +import org.redisson.api.RBucket; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Date; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Token管理器 - 使用Redisson进行Token存储和管理 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +@Component +public class TokenManager { + + public JwtUtil jwtUtil; + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private RedissonClient redissonClient; + + /** + * 生成Token对 + * + * @param userDetails 用户详情 + * @return Token对 + */ + public TokenPair generateToken(UserDetails userDetails) { + try { + String clientId = userDetails.getClientId() != null ? userDetails.getClientId() : "default"; + Set scopes = userDetails.getAuthorities() != null ? + userDetails.getAuthorities() : new HashSet<>(Arrays.asList("read", "write")); + + // 生成JWT token + String accessToken = jwtUtil.generateAccessToken(userDetails.getUserId(), clientId, scopes); + String refreshToken = jwtUtil.generateRefreshToken(userDetails.getUserId(), clientId); + + // 将token存储到Redis中 + storeTokenInRedis(accessToken, refreshToken, userDetails); + + TokenPair tokenPair = new TokenPair(); + tokenPair.setAccessToken(accessToken); + tokenPair.setRefreshToken(refreshToken); + tokenPair.setExpiresIn(jwtUtil.getAccessTokenExpiration()); + tokenPair.setTokenType("Bearer"); + tokenPair.setScope(String.join(" ", scopes)); + + return tokenPair; + } catch (Exception e) { + throw TokenException.tokenGenerationFailed(); + } + } + + /** + * 验证Token + * + * @param token 访问令牌 + * @return 验证结果 + */ + public TokenValidation validateToken(String token) { + try { + // 首先验证JWT格式和签名 + Claims claims = jwtUtil.validateAndParseToken(token); + + // 检查Redis中是否存在该token + String accessTokenKey = OAuthConstants.Token.ACCESS_TOKEN_PREFIX + token; + RBucket bucket = redissonClient.getBucket(accessTokenKey); + + if (!bucket.isExists()) { + return TokenValidation.invalid("Token不存在或已被撤销"); + } + + // 提取token信息 + Long userId = Long.valueOf(claims.getSubject()); + String clientId = claims.get("client_id", String.class); + String scope = claims.get("scope", String.class); + Set scopes = scope != null ? + new HashSet<>(Arrays.asList(scope.split(" "))) : new HashSet<>(); + Date expirationTime = claims.getExpiration(); + + return TokenValidation.valid(userId, scopes, expirationTime, clientId); + } catch (TokenException e) { + return TokenValidation.invalid(e.getErrorDescription()); + } catch (Exception e) { + return TokenValidation.invalid("Token验证失败: " + e.getMessage()); + } + } + + /** + * 刷新Token + * + * @param refreshToken 刷新令牌 + * @param userDetails 用户详情 + * @return 新的Token对 + */ + public TokenPair refreshToken(String refreshToken, UserDetails userDetails) { + try { + // 验证refresh token + Claims claims = jwtUtil.validateAndParseToken(refreshToken); + String tokenType = claims.get("token_type", String.class); + + if (!"refresh_token".equals(tokenType)) { + throw TokenException.invalidRefreshToken(); + } + + // 检查Redis中是否存在该refresh token + String refreshTokenKey = OAuthConstants.Token.REFRESH_TOKEN_PREFIX + refreshToken; + RBucket bucket = redissonClient.getBucket(refreshTokenKey); + + if (!bucket.isExists()) { + throw TokenException.invalidRefreshToken(); + } + + // 撤销旧的tokens + revokeTokensByUserId(userDetails.getUserId()); + + // 生成新的token对 + return generateToken(userDetails); + } catch (TokenException e) { + throw e; + } catch (Exception e) { + throw TokenException.invalidRefreshToken(); + } + } + + /** + * 撤销Token + * + * @param token 访问令牌 + */ + public void revokeToken(String token) { + try { + Claims claims = jwtUtil.validateAndParseToken(token); + Long userId = Long.valueOf(claims.getSubject()); + + // 删除access token + String accessTokenKey = OAuthConstants.Token.ACCESS_TOKEN_PREFIX + token; + redissonClient.getBucket(accessTokenKey).delete(); + + // 查找并删除对应的refresh token + revokeTokensByUserId(userId); + } catch (Exception e) { + // 忽略撤销失败的情况 + } + } + + /** + * 撤销用户所有Token + * + * @param userId 用户ID + */ + public void revokeTokensByUserId(Long userId) { + try { + String userTokenKey = OAuthConstants.Token.USER_TOKEN_PREFIX + userId; + RBucket userTokenBucket = redissonClient.getBucket(userTokenKey); + + if (userTokenBucket.isExists()) { + // 可以在这里实现更复杂的用户token管理逻辑 + // 暂时简单删除用户关联的token记录 + userTokenBucket.delete(); + } + } catch (Exception e) { + // 忽略撤销失败的情况 + } + } + + /** + * 将Token存储到Redis + * + * @param accessToken 访问令牌 + * @param refreshToken 刷新令牌 + * @param userDetails 用户详情 + */ + private void storeTokenInRedis(String accessToken, String refreshToken, UserDetails userDetails) { + // 存储access token + String accessTokenKey = OAuthConstants.Token.ACCESS_TOKEN_PREFIX + accessToken; + RBucket accessTokenBucket = redissonClient.getBucket(accessTokenKey); + accessTokenBucket.set(userDetails.getUserId().toString(), + jwtUtil.getAccessTokenExpiration(), TimeUnit.SECONDS); + + // 存储refresh token + String refreshTokenKey = OAuthConstants.Token.REFRESH_TOKEN_PREFIX + refreshToken; + RBucket refreshTokenBucket = redissonClient.getBucket(refreshTokenKey); + refreshTokenBucket.set(userDetails.getUserId().toString(), + jwtUtil.getRefreshTokenExpiration(), TimeUnit.SECONDS); + + // 存储用户token关联(可用于实现单点登录控制) + String userTokenKey = OAuthConstants.Token.USER_TOKEN_PREFIX + userDetails.getUserId(); + RBucket userTokenBucket = redissonClient.getBucket(userTokenKey); + userTokenBucket.set(accessToken, jwtUtil.getAccessTokenExpiration(), TimeUnit.SECONDS); + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/service/AuthenticationService.java b/accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/service/AuthenticationService.java new file mode 100644 index 000000000..cf03ebd44 --- /dev/null +++ b/accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/service/AuthenticationService.java @@ -0,0 +1,202 @@ +package com.accompany.oauth.service; + +import com.accompany.oauth.constant.AuthType; +import com.accompany.oauth.dto.AuthCredentials; +import com.accompany.oauth.dto.AuthResult; +import com.accompany.oauth.dto.UserInfo; +import com.accompany.oauth.exception.AuthenticationException; +import com.accompany.oauth.manager.TokenManager; +import com.accompany.oauth.model.TokenPair; +import com.accompany.oauth.model.TokenValidation; +import com.accompany.oauth.model.UserDetails; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * 认证服务 - 统一认证入口 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +@Service +public class AuthenticationService { + + @Autowired + private UserService userService; + + @Autowired + private TokenManager tokenManager; + + /** + * 用户认证 + * + * @param credentials 认证凭据 + * @return 认证结果 + * @throws AuthenticationException 认证失败 + */ + public AuthResult authenticate(AuthCredentials credentials) { + // 1. 根据认证类型进行用户认证 + UserDetails userDetails = authenticateUser(credentials); + + // 2. 检查用户状态 + userService.checkUserStatus(userDetails); + + // 3. 设置客户端ID和权限范围 + userDetails.setClientId(credentials.getClientId()); + + // 4. 生成Token + TokenPair tokenPair = tokenManager.generateToken(userDetails); + + // 5. 构建认证结果 + return buildAuthResult(tokenPair, userDetails); + } + + /** + * 验证Token + * + * @param token 访问令牌 + * @return Token验证结果 + */ + public TokenValidation validateToken(String token) { + return tokenManager.validateToken(token); + } + + /** + * 刷新Token + * + * @param refreshToken 刷新令牌 + * @return 认证结果 + * @throws AuthenticationException 刷新失败 + */ + public AuthResult refreshToken(String refreshToken) { + try { + // 1. 从refresh token中获取用户信息 + Long userId = tokenManager.jwtUtil.getUserIdFromToken(refreshToken); + UserDetails userDetails = userService.getUserById(userId); + + // 2. 检查用户状态 + userService.checkUserStatus(userDetails); + + // 3. 刷新Token + TokenPair tokenPair = tokenManager.refreshToken(refreshToken, userDetails); + + // 4. 构建认证结果 + return buildAuthResult(tokenPair, userDetails); + } catch (Exception e) { + throw new AuthenticationException("刷新Token失败: " + e.getMessage()); + } + } + + /** + * 注销Token + * + * @param token 访问令牌 + */ + public void revokeToken(String token) { + tokenManager.revokeToken(token); + } + + /** + * 注销用户所有Token + * + * @param userId 用户ID + */ + public void revokeAllTokens(Long userId) { + tokenManager.revokeTokensByUserId(userId); + } + + /** + * 根据认证类型进行用户认证 + * + * @param credentials 认证凭据 + * @return 用户详情 + */ + private UserDetails authenticateUser(AuthCredentials credentials) { + switch (credentials.getType()) { + case PASSWORD: + return userService.authenticateByPassword( + credentials.getPrincipal(), credentials.getCredentials()); + + case VERIFY_CODE: + return userService.authenticateByVerifyCode( + credentials.getPrincipal(), credentials.getCredentials()); + + case EMAIL: + return userService.authenticateByEmail( + credentials.getPrincipal(), credentials.getCredentials()); + + case OPENID: + case APPLE: + // 对于第三方登录,credentials中包含第三方类型信息 + return userService.authenticateByOpenId( + credentials.getPrincipal(), (byte) 1); + + default: + throw new AuthenticationException("不支持的认证类型: " + credentials.getType()); + } + } + + /** + * 构建认证结果 + * + * @param tokenPair Token对 + * @param userDetails 用户详情 + * @return 认证结果 + */ + private AuthResult buildAuthResult(TokenPair tokenPair, UserDetails userDetails) { + // 构建用户信息 + UserInfo userInfo = new UserInfo(); + userInfo.setUserId(userDetails.getUserId()); + userInfo.setUsername(userDetails.getUsername()); + userInfo.setPhone(maskPhone(userDetails.getPhone())); + userInfo.setEmail(maskEmail(userDetails.getEmail())); + userInfo.setStatus(userDetails.getStatus().getCode()); + + // 构建认证结果 + AuthResult authResult = new AuthResult(); + authResult.setAccessToken(tokenPair.getAccessToken()); + authResult.setRefreshToken(tokenPair.getRefreshToken()); + authResult.setExpiresIn(tokenPair.getExpiresIn()); + authResult.setTokenType(tokenPair.getTokenType()); + authResult.setScope(tokenPair.getScope()); + authResult.setUserInfo(userInfo); + + // 填充兼容字段 + authResult.setUid(userDetails.getUserId()); + authResult.setNetEaseToken(""); // TODO: 需要根据实际业务填充 + authResult.setAccid(""); // TODO: 需要根据实际业务填充 + + return authResult; + } + + /** + * 手机号脱敏 + * + * @param phone 原始手机号 + * @return 脱敏后的手机号 + */ + private String maskPhone(String phone) { + if (phone == null || phone.length() < 11) { + return phone; + } + return phone.substring(0, 3) + "****" + phone.substring(7); + } + + /** + * 邮箱脱敏 + * + * @param email 原始邮箱 + * @return 脱敏后的邮箱 + */ + private String maskEmail(String email) { + if (email == null || !email.contains("@")) { + return email; + } + String[] parts = email.split("@"); + String localPart = parts[0]; + if (localPart.length() <= 2) { + return email; + } + return localPart.substring(0, 2) + "***@" + parts[1]; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/service/UserService.java b/accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/service/UserService.java new file mode 100644 index 000000000..a19110b37 --- /dev/null +++ b/accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/service/UserService.java @@ -0,0 +1,167 @@ +package com.accompany.oauth.service; + +import com.accompany.oauth.constant.UserStatus; +import com.accompany.oauth.exception.AuthenticationException; +import com.accompany.oauth.model.UserDetails; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.HashSet; + +/** + * 用户服务 - 用户认证和信息查询 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +@Service +public class UserService { + + /** + * 通过密码认证用户 + * + * @param phone 手机号 + * @param password 密码 + * @return 用户详情 + * @throws AuthenticationException 认证失败 + */ + public UserDetails authenticateByPassword(String phone, String password) { + // TODO: 实现密码认证逻辑,需要连接用户数据库 + // 这里先提供一个模拟实现 + + if ("13800138000".equals(phone) && "123456".equals(password)) { + UserDetails userDetails = new UserDetails(); + userDetails.setUserId(1L); + userDetails.setPhone(phone); + userDetails.setUsername("test_user"); + userDetails.setStatus(UserStatus.NORMAL); + userDetails.setAuthorities(new HashSet<>(Arrays.asList("read", "write"))); + return userDetails; + } + + throw AuthenticationException.invalidCredentials(); + } + + /** + * 通过验证码认证用户 + * + * @param phone 手机号 + * @param code 验证码 + * @return 用户详情 + * @throws AuthenticationException 认证失败 + */ + public UserDetails authenticateByVerifyCode(String phone, String code) { + // TODO: 实现验证码认证逻辑 + // 1. 验证验证码是否正确且未过期 + // 2. 查询用户信息 + // 3. 检查用户状态 + + if ("13800138000".equals(phone) && "888888".equals(code)) { + UserDetails userDetails = new UserDetails(); + userDetails.setUserId(1L); + userDetails.setPhone(phone); + userDetails.setUsername("test_user"); + userDetails.setStatus(UserStatus.NORMAL); + userDetails.setAuthorities(new HashSet<>(Arrays.asList("read", "write"))); + return userDetails; + } + + throw AuthenticationException.invalidVerifyCode(); + } + + /** + * 通过邮箱认证用户 + * + * @param email 邮箱 + * @param code 验证码 + * @return 用户详情 + * @throws AuthenticationException 认证失败 + */ + public UserDetails authenticateByEmail(String email, String code) { + // TODO: 实现邮箱认证逻辑 + // 1. 验证邮箱验证码是否正确且未过期 + // 2. 查询用户信息 + // 3. 检查用户状态 + + if ("test@example.com".equals(email) && "666666".equals(code)) { + UserDetails userDetails = new UserDetails(); + userDetails.setUserId(2L); + userDetails.setEmail(email); + userDetails.setUsername("email_user"); + userDetails.setStatus(UserStatus.NORMAL); + userDetails.setAuthorities(new HashSet<>(Arrays.asList("read", "write"))); + return userDetails; + } + + throw AuthenticationException.invalidVerifyCode(); + } + + /** + * 通过OpenID认证用户 + * + * @param openId OpenID + * @param type 第三方类型 (1-微信, 2-Apple等) + * @return 用户详情 + * @throws AuthenticationException 认证失败 + */ + public UserDetails authenticateByOpenId(String openId, Byte type) { + // TODO: 实现OpenID认证逻辑 + // 1. 验证OpenID的有效性 + // 2. 查询绑定的用户信息 + // 3. 检查用户状态 + + if ("openid_test_123".equals(openId)) { + UserDetails userDetails = new UserDetails(); + userDetails.setUserId(3L); + userDetails.setUsername("openid_user"); + userDetails.setStatus(UserStatus.NORMAL); + userDetails.setAuthorities(new HashSet<>(Arrays.asList("read", "write"))); + return userDetails; + } + + throw AuthenticationException.userNotFound(); + } + + /** + * 根据用户ID获取用户详情 + * + * @param userId 用户ID + * @return 用户详情 + * @throws AuthenticationException 用户不存在 + */ + public UserDetails getUserById(Long userId) { + // TODO: 实现根据用户ID查询用户信息的逻辑 + + if (userId != null && userId > 0) { + UserDetails userDetails = new UserDetails(); + userDetails.setUserId(userId); + userDetails.setPhone("138****8000"); + userDetails.setUsername("user_" + userId); + userDetails.setStatus(UserStatus.NORMAL); + userDetails.setAuthorities(new HashSet<>(Arrays.asList("read", "write"))); + return userDetails; + } + + throw AuthenticationException.userNotFound(); + } + + /** + * 检查用户状态是否可用 + * + * @param userDetails 用户详情 + * @throws AuthenticationException 用户不可用 + */ + public void checkUserStatus(UserDetails userDetails) { + if (userDetails == null) { + throw AuthenticationException.userNotFound(); + } + + if (userDetails.getStatus() == UserStatus.FROZEN) { + throw AuthenticationException.userFrozen(); + } + + if (userDetails.getStatus() == UserStatus.DELETED) { + throw AuthenticationException.userNotFound(); + } + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/util/JwtUtil.java b/accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/util/JwtUtil.java new file mode 100644 index 000000000..df31fb274 --- /dev/null +++ b/accompany-oauth/accompany-oauth-service/src/main/java/com/accompany/oauth/util/JwtUtil.java @@ -0,0 +1,190 @@ +package com.accompany.oauth.util; + +import com.accompany.oauth.exception.TokenException; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Map; +import java.util.Set; + +/** + * JWT工具类 - 使用更安全的实现 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +@Component +public class JwtUtil { + + @Value("${oauth.jwt.secret:accompany-oauth-secret-key-for-jwt-token-generation}") + private String secret; + + @Value("${oauth.jwt.access-token-expiration:7200}") + private long accessTokenExpiration; + + @Value("${oauth.jwt.refresh-token-expiration:2592000}") + private long refreshTokenExpiration; + + private SecretKey secretKey; + + @PostConstruct + public void init() { + // 确保密钥长度足够 + if (secret.getBytes(StandardCharsets.UTF_8).length < 32) { + secret = secret + "0123456789abcdef0123456789abcdef"; + } + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + /** + * 生成访问令牌 + * + * @param userId 用户ID + * @param clientId 客户端ID + * @param scopes 权限范围 + * @return JWT令牌 + */ + public String generateAccessToken(Long userId, String clientId, Set scopes) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + accessTokenExpiration * 1000); + + JwtBuilder builder = Jwts.builder() + .setSubject(userId.toString()) + .setIssuedAt(now) + .setExpiration(expiration) + .claim("client_id", clientId) + .claim("token_type", "access_token") + .signWith(secretKey, SignatureAlgorithm.HS256); + + if (scopes != null && !scopes.isEmpty()) { + builder.claim("scope", String.join(" ", scopes)); + } + + return builder.compact(); + } + + /** + * 生成刷新令牌 + * + * @param userId 用户ID + * @param clientId 客户端ID + * @return JWT令牌 + */ + public String generateRefreshToken(Long userId, String clientId) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + refreshTokenExpiration * 1000); + + return Jwts.builder() + .setSubject(userId.toString()) + .setIssuedAt(now) + .setExpiration(expiration) + .claim("client_id", clientId) + .claim("token_type", "refresh_token") + .signWith(secretKey, SignatureAlgorithm.HS256) + .compact(); + } + + /** + * 验证并解析JWT令牌 + * + * @param token JWT令牌 + * @return Claims对象 + * @throws TokenException 令牌无效或过期 + */ + public Claims validateAndParseToken(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(secretKey) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException e) { + throw TokenException.tokenExpired(); + } catch (JwtException e) { + throw TokenException.invalidToken(); + } + } + + /** + * 从令牌中获取用户ID + * + * @param token JWT令牌 + * @return 用户ID + */ + public Long getUserIdFromToken(String token) { + Claims claims = validateAndParseToken(token); + return Long.valueOf(claims.getSubject()); + } + + /** + * 从令牌中获取客户端ID + * + * @param token JWT令牌 + * @return 客户端ID + */ + public String getClientIdFromToken(String token) { + Claims claims = validateAndParseToken(token); + return claims.get("client_id", String.class); + } + + /** + * 从令牌中获取权限范围 + * + * @param token JWT令牌 + * @return 权限范围 + */ + public String getScopeFromToken(String token) { + Claims claims = validateAndParseToken(token); + return claims.get("scope", String.class); + } + + /** + * 检查令牌是否过期 + * + * @param token JWT令牌 + * @return 是否过期 + */ + public boolean isTokenExpired(String token) { + try { + Claims claims = validateAndParseToken(token); + return claims.getExpiration().before(new Date()); + } catch (TokenException e) { + return true; + } + } + + /** + * 获取令牌过期时间 + * + * @param token JWT令牌 + * @return 过期时间 + */ + public Date getExpirationFromToken(String token) { + Claims claims = validateAndParseToken(token); + return claims.getExpiration(); + } + + /** + * 获取访问令牌有效期(秒) + * + * @return 有效期秒数 + */ + public long getAccessTokenExpiration() { + return accessTokenExpiration; + } + + /** + * 获取刷新令牌有效期(秒) + * + * @return 有效期秒数 + */ + public long getRefreshTokenExpiration() { + return refreshTokenExpiration; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-web/pom.xml b/accompany-oauth/accompany-oauth-web/pom.xml new file mode 100644 index 000000000..406875321 --- /dev/null +++ b/accompany-oauth/accompany-oauth-web/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + com.accompany + accompany-oauth + 1.0.0 + + + accompany-oauth-web + jar + OAuth Web模块 - 控制器和Web配置 + + + + com.accompany + accompany-oauth-service + ${revision} + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.accompany.oauth.OAuthApplication + + + + + repackage + + + exec + + + + + + + \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/OAuthApplication.java b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/OAuthApplication.java new file mode 100644 index 000000000..da09dc120 --- /dev/null +++ b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/OAuthApplication.java @@ -0,0 +1,42 @@ +package com.accompany.oauth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; + +/** + * OAuth应用程序启动类 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +@SpringBootApplication +@ComponentScan(basePackages = { + "com.accompany.oauth", // OAuth模块 + "com.accompany.common", // 公共模块 + "com.accompany.core" // 核心模块 +}) +public class OAuthApplication { + + public static void main(String[] args) { + // 设置系统属性 + System.setProperty("spring.application.name", "accompany-oauth"); + + SpringApplication app = new SpringApplication(OAuthApplication.class); + + // 添加启动横幅 + app.setBanner((environment, sourceClass, out) -> { + out.println(); + out.println(" ____ "); + out.println(" / __ \\ ___ __ __ ___ __ __ ___ ___ "); + out.println("/ /_/ / / / / V / / / / V / / / / / "); + out.println("\\____/ /__/ /_/\\_/ /__/ /_/\\_/ /__/ /__/ "); + out.println(); + out.println(":: Accompany OAuth Service :: (v1.0.0)"); + out.println(":: Powered by Spring Boot :: "); + out.println(); + }); + + app.run(args); + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/config/AuthResultJsonSerializer.java b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/config/AuthResultJsonSerializer.java new file mode 100644 index 000000000..e9d266d26 --- /dev/null +++ b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/config/AuthResultJsonSerializer.java @@ -0,0 +1,63 @@ +package com.accompany.oauth.config; + +import com.accompany.oauth.dto.AuthResult; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import org.springframework.boot.jackson.JsonComponent; + +import java.io.IOException; + +/** + * AuthResult自定义序列化器 - 兼容OAuth2的CustomOAuth2AccessToken格式 + * 输出格式:{code:200, data:{uid, access_token, token_type, expires_in, ...}} + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +@JsonComponent +public class AuthResultJsonSerializer extends JsonSerializer { + + @Override + public void serialize(AuthResult authResult, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + + gen.writeStartObject(); + + // 写入状态码 + gen.writeNumberField("code", 200); + + // 写入data对象 + gen.writeObjectFieldStart("data"); + + // 用户ID (兼容oauth2) + gen.writeNumberField("uid", authResult.getUid()); + + // 网易云信Token (兼容oauth2) + gen.writeStringField("netEaseToken", authResult.getNetEaseToken()); + + // accid (兼容oauth2) + if (authResult.getAccid() != null) { + gen.writeStringField("accid", authResult.getAccid()); + } + + // 标准OAuth字段 + gen.writeStringField("access_token", authResult.getAccessToken()); + gen.writeStringField("token_type", authResult.getTokenType()); + + if (authResult.getRefreshToken() != null) { + gen.writeStringField("refresh_token", authResult.getRefreshToken()); + } + + if (authResult.getExpiresIn() != null) { + gen.writeNumberField("expires_in", authResult.getExpiresIn()); + } + + if (authResult.getScope() != null) { + gen.writeStringField("scope", authResult.getScope()); + } + + gen.writeEndObject(); // end data + gen.writeEndObject(); // end root + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/config/GlobalExceptionHandler.java b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/config/GlobalExceptionHandler.java new file mode 100644 index 000000000..15d3f535a --- /dev/null +++ b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/config/GlobalExceptionHandler.java @@ -0,0 +1,122 @@ +package com.accompany.oauth.config; + +import com.accompany.oauth.exception.AuthenticationException; +import com.accompany.oauth.exception.OAuthException; +import com.accompany.oauth.exception.TokenException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.util.HashMap; +import java.util.Map; + +/** + * 全局异常处理器 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +@ControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * 处理OAuth异常 + * + * @param e OAuth异常 + * @return 错误响应 + */ + @ExceptionHandler(OAuthException.class) + @ResponseBody + public ResponseEntity> handleOAuthException(OAuthException e) { + logger.warn("OAuth异常: {} - {}", e.getErrorCode(), e.getErrorDescription()); + + Map response = new HashMap<>(); + response.put("error", e.getErrorCode()); + response.put("error_description", e.getErrorDescription()); + response.put("timestamp", System.currentTimeMillis()); + + return ResponseEntity.badRequest().body(response); + } + + /** + * 处理认证异常 + * + * @param e 认证异常 + * @return 错误响应 + */ + @ExceptionHandler(AuthenticationException.class) + @ResponseBody + public ResponseEntity> handleAuthenticationException(AuthenticationException e) { + logger.warn("认证异常: {}", e.getErrorDescription()); + + Map response = new HashMap<>(); + response.put("error", e.getErrorCode()); + response.put("error_description", e.getErrorDescription()); + response.put("timestamp", System.currentTimeMillis()); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); + } + + /** + * 处理Token异常 + * + * @param e Token异常 + * @return 错误响应 + */ + @ExceptionHandler(TokenException.class) + @ResponseBody + public ResponseEntity> handleTokenException(TokenException e) { + logger.warn("Token异常: {} - {}", e.getErrorCode(), e.getErrorDescription()); + + Map response = new HashMap<>(); + response.put("error", e.getErrorCode()); + response.put("error_description", e.getErrorDescription()); + response.put("timestamp", System.currentTimeMillis()); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); + } + + /** + * 处理参数异常 + * + * @param e 参数异常 + * @return 错误响应 + */ + @ExceptionHandler(IllegalArgumentException.class) + @ResponseBody + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + logger.warn("参数异常: {}", e.getMessage()); + + Map response = new HashMap<>(); + response.put("error", "invalid_request"); + response.put("error_description", e.getMessage()); + response.put("timestamp", System.currentTimeMillis()); + + return ResponseEntity.badRequest().body(response); + } + + /** + * 处理其他异常 + * + * @param e 异常 + * @return 错误响应 + */ + @ExceptionHandler(Exception.class) + @ResponseBody + public ResponseEntity> handleException(Exception e) { + logger.error("系统异常", e); + + Map response = new HashMap<>(); + response.put("error", "server_error"); + response.put("error_description", "系统内部错误"); + response.put("timestamp", System.currentTimeMillis()); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/config/WebConfig.java b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/config/WebConfig.java new file mode 100644 index 000000000..cd728f814 --- /dev/null +++ b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/config/WebConfig.java @@ -0,0 +1,51 @@ +package com.accompany.oauth.config; + +import com.accompany.oauth.interceptor.AuthenticationInterceptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web配置类 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Autowired + private AuthenticationInterceptor authenticationInterceptor; + + /** + * 添加拦截器 + */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authenticationInterceptor) + .addPathPatterns("/**") + .excludePathPatterns( + "/oauth/**", // OAuth认证相关接口 + "/acc/logout", // 用户注销接口 + "/actuator/**", // 健康检查接口 + "/swagger-ui/**", // Swagger文档 + "/v3/api-docs/**", // API文档 + "/favicon.ico" // 图标 + ); + } + + /** + * 配置跨域 + */ + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/controller/AccountController.java b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/controller/AccountController.java new file mode 100644 index 000000000..3e7962b9f --- /dev/null +++ b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/controller/AccountController.java @@ -0,0 +1,130 @@ +package com.accompany.oauth.controller; + +import com.accompany.common.constant.AppEnum; +import com.accompany.common.device.DeviceInfo; +import com.accompany.common.result.BusiResult; +import com.accompany.oauth.dto.AuthResult; +import com.accompany.oauth.model.TokenValidation; +import com.accompany.oauth.service.AuthenticationService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; + +/** + * 用户账户控制器 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +@RestController +@RequestMapping("/acc") +public class AccountController { + + @Autowired + private AuthenticationService authenticationService; + + /** + * 用户注销 (兼容OAuth2格式) + * + * @param accessToken 访问令牌 + * @return BusiResult响应结果 + */ + @PostMapping("/logout") + public BusiResult logout(@RequestParam("access_token") String accessToken) { + if (StringUtils.hasText(accessToken)) { + authenticationService.revokeToken(accessToken); + } + return BusiResult.success(); + } + + /** + * 第三方登录 (兼容OAuth2格式) + * + * @param openid OpenID + * @param type 第三方类型 + * @param unionid UnionID(可选) + * @param deviceInfo 设备信息 + * @param app 应用类型 + * @param idToken ID Token(可选) + * @param request HTTP请求 + * @return 直接返回AuthResult结构 + */ + @RequestMapping("/third/login") + public AuthResult thirdLogin(@RequestParam String openid, + @RequestParam Integer type, + @RequestParam(required = false) String unionid, + DeviceInfo deviceInfo, + @RequestParam(required = false) AppEnum app, + @RequestParam(required = false) String idToken, + HttpServletRequest request) { + // TODO: 实现第三方登录逻辑 + // 1. 验证第三方登录信息 + // 2. 查询或创建用户 + // 3. 生成Token + + throw new UnsupportedOperationException("第三方登录功能暂未实现"); + } + + /** + * 重置密码 (兼容OAuth2格式) + * + * @param requestBody 重置密码请求 + * @return BusiResult响应结果 + */ + @PostMapping("/pwd/reset") + public BusiResult resetPassword(@RequestBody Map requestBody) { + // TODO: 实现密码重置逻辑 + // 1. 验证用户身份(手机号+验证码或邮箱+验证码) + // 2. 重置密码 + // 3. 发送通知 + + throw new UnsupportedOperationException("密码重置功能暂未实现"); + } + + /** + * 修改密码 (兼容OAuth2格式) + * + * @param requestBody 修改密码请求 + * @param request HTTP请求 + * @return BusiResult响应结果 + */ + @PostMapping("/pwd/modify") + public BusiResult modifyPassword(@RequestBody Map requestBody, + HttpServletRequest request) { + // 验证用户Token + String token = extractTokenFromRequest(request); + if (!StringUtils.hasText(token)) { + throw new IllegalArgumentException("缺少访问令牌"); + } + + TokenValidation validation = authenticationService.validateToken(token); + if (!validation.isValid()) { + throw new IllegalArgumentException("无效的访问令牌"); + } + + // TODO: 实现密码修改逻辑 + // 1. 验证原密码 + // 2. 修改为新密码 + // 3. 撤销所有Token(强制重新登录) + + throw new UnsupportedOperationException("密码修改功能暂未实现"); + } + + /** + * 从请求中提取Token + * + * @param request HTTP请求 + * @return Token字符串 + */ + private String extractTokenFromRequest(HttpServletRequest request) { + String authorization = request.getHeader("Authorization"); + if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) { + return authorization.substring(7); + } + return null; + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/controller/OAuthController.java b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/controller/OAuthController.java new file mode 100644 index 000000000..0289b8982 --- /dev/null +++ b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/controller/OAuthController.java @@ -0,0 +1,185 @@ +package com.accompany.oauth.controller; + +import com.accompany.common.result.BusiResult; +import com.accompany.oauth.constant.AuthType; +import com.accompany.oauth.constant.OAuthConstants; +import com.accompany.oauth.dto.AuthCredentials; +import com.accompany.oauth.dto.AuthResult; +import com.accompany.oauth.dto.TokenRequest; +import com.accompany.oauth.dto.H5AccessToken; +import com.accompany.oauth.dto.DeviceInfo; +import com.accompany.oauth.service.AuthenticationService; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import java.util.HashMap; +import java.util.Map; + +/** + * OAuth认证控制器 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +@RestController +@RequestMapping("/oauth") +public class OAuthController { + + @Autowired + private AuthenticationService authenticationService; + + /** + * 统一认证端点 - 获取Token (兼容OAuth2格式) + * + * @param request Token请求 + * @param httpRequest HTTP请求 + * @return 直接返回AuthResult结构,兼容OAuth2的CustomOAuth2AccessToken + */ + @PostMapping("/token") + public AuthResult token(@RequestBody TokenRequest request, + HttpServletRequest httpRequest) { + // 1. 验证请求参数 + validateTokenRequest(request); + + // 2. 构建认证凭据 + AuthCredentials credentials = buildAuthCredentials(request, httpRequest); + + // 3. 执行认证 + AuthResult authResult = authenticationService.authenticate(credentials); + + return authResult; + } + + /** + * 刷新Token端点 (兼容OAuth2格式) + * + * @param request Token刷新请求 + * @return 直接返回AuthResult结构 + */ + @PostMapping("/refresh") + public AuthResult refresh(@RequestBody TokenRequest request) { + if (!OAuthConstants.GrantType.REFRESH_TOKEN.equals(request.getGrantType()) || + !StringUtils.hasText(request.getRefreshToken())) { + throw new IllegalArgumentException("缺少刷新令牌"); + } + + // 执行Token刷新 + AuthResult authResult = authenticationService.refreshToken(request.getRefreshToken()); + + return authResult; + } + + /** + * 撤销Token端点 (兼容OAuth2格式) + * + * @param token 要撤销的Token + * @return BusiResult响应结果 + */ + @PostMapping("/revoke") + public BusiResult revoke(@RequestParam("token") String token) { + if (!StringUtils.hasText(token)) { + throw new IllegalArgumentException("缺少Token参数"); + } + + authenticationService.revokeToken(token); + + return BusiResult.success(); + } + + /** + * H5授权登录端点 (兼容OAuth2格式) + * + * @param request Token请求 + * @param httpRequest HTTP请求 + * @return BusiResult包装的H5AccessToken + */ + @PostMapping("/h5/token") + public BusiResult h5Token(@RequestBody TokenRequest request, + HttpServletRequest httpRequest) { + // 执行认证 + AuthResult authResult = token(request, httpRequest); + + // 转换为H5格式 + H5AccessToken h5Token = H5AccessToken.fromAuthResult(authResult); + + return BusiResult.success(h5Token); + } + + /** + * 验证Token请求参数 + * + * @param request Token请求 + */ + private void validateTokenRequest(TokenRequest request) { + if (!StringUtils.hasText(request.getGrantType())) { + throw new IllegalArgumentException("缺少grant_type参数"); + } + + if (!StringUtils.hasText(request.getUsername()) && + !OAuthConstants.GrantType.REFRESH_TOKEN.equals(request.getGrantType())) { + throw new IllegalArgumentException("缺少username参数"); + } + + if (!StringUtils.hasText(request.getPassword()) && + !OAuthConstants.GrantType.REFRESH_TOKEN.equals(request.getGrantType())) { + throw new IllegalArgumentException("缺少password参数"); + } + } + + /** + * 构建认证凭据 + * + * @param request Token请求 + * @param httpRequest HTTP请求 + * @return 认证凭据 + */ + private AuthCredentials buildAuthCredentials(TokenRequest request, HttpServletRequest httpRequest) { + AuthCredentials credentials = new AuthCredentials(); + + // 设置认证类型 + AuthType authType = AuthType.fromCode(request.getGrantType()); + credentials.setType(authType); + + // 设置主体和凭据 + credentials.setPrincipal(request.getUsername()); + credentials.setCredentials(request.getPassword()); + + // 设置客户端信息 + credentials.setClientId(StringUtils.hasText(request.getClientId()) ? + request.getClientId() : "default"); + + // 设置权限范围 + credentials.setScope(StringUtils.hasText(request.getScope()) ? + request.getScope() : OAuthConstants.Scope.ALL); + + // 构建设备信息 + DeviceInfo deviceInfo = new DeviceInfo(); + deviceInfo.setDeviceId(request.getDeviceId()); + deviceInfo.setIpAddress(getClientIpAddress(httpRequest)); + deviceInfo.setUserAgent(httpRequest.getHeader("User-Agent")); + credentials.setDeviceInfo(deviceInfo); + + return credentials; + } + + /** + * 获取客户端IP地址 + * + * @param request HTTP请求 + * @return IP地址 + */ + private String getClientIpAddress(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (StringUtils.hasText(xForwardedFor)) { + return xForwardedFor.split(",")[0].trim(); + } + + String xRealIp = request.getHeader("X-Real-IP"); + if (StringUtils.hasText(xRealIp)) { + return xRealIp; + } + + return request.getRemoteAddr(); + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/interceptor/AuthenticationInterceptor.java b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/interceptor/AuthenticationInterceptor.java new file mode 100644 index 000000000..941f5228d --- /dev/null +++ b/accompany-oauth/accompany-oauth-web/src/main/java/com/accompany/oauth/interceptor/AuthenticationInterceptor.java @@ -0,0 +1,117 @@ +package com.accompany.oauth.interceptor; + +import com.accompany.oauth.constant.OAuthConstants; +import com.accompany.oauth.model.TokenValidation; +import com.accompany.oauth.service.AuthenticationService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.HashMap; +import java.util.Map; + +/** + * 认证拦截器 - 验证访问令牌 + * + * @author Accompany OAuth Team + * @since 1.0.0 + */ +@Component +public class AuthenticationInterceptor implements HandlerInterceptor { + + @Autowired + private AuthenticationService authenticationService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + + // 跳过OPTIONS请求 + if ("OPTIONS".equals(request.getMethod())) { + return true; + } + + // 跳过认证相关接口 + String requestURI = request.getRequestURI(); + if (isAuthEndpoint(requestURI)) { + return true; + } + + // 提取Token + String token = extractToken(request); + if (!StringUtils.hasText(token)) { + writeUnauthorizedResponse(response, "缺少访问令牌"); + return false; + } + + // 验证Token + TokenValidation validation = authenticationService.validateToken(token); + if (!validation.isValid()) { + writeUnauthorizedResponse(response, validation.getErrorMessage()); + return false; + } + + // 将用户信息存储到请求属性中 + request.setAttribute("userId", validation.getUserId()); + request.setAttribute("clientId", validation.getClientId()); + request.setAttribute("scopes", validation.getScopes()); + + return true; + } + + /** + * 提取访问令牌 + * + * @param request HTTP请求 + * @return 访问令牌 + */ + private String extractToken(HttpServletRequest request) { + // 优先从Header中获取 + String authorization = request.getHeader(OAuthConstants.Headers.AUTHORIZATION); + if (StringUtils.hasText(authorization) && authorization.startsWith(OAuthConstants.Token.BEARER_PREFIX)) { + return authorization.substring(OAuthConstants.Token.BEARER_PREFIX.length()); + } + + // 从参数中获取 + return request.getParameter(OAuthConstants.Token.ACCESS_TOKEN); + } + + /** + * 判断是否是认证相关端点 + * + * @param requestURI 请求URI + * @return 是否是认证端点 + */ + private boolean isAuthEndpoint(String requestURI) { + return requestURI.startsWith("/oauth/") || + requestURI.equals("/acc/logout") || + requestURI.startsWith("/actuator/") || + requestURI.startsWith("/swagger-") || + requestURI.startsWith("/v3/api-docs"); + } + + /** + * 写入未授权响应 + * + * @param response HTTP响应 + * @param message 错误消息 + */ + private void writeUnauthorizedResponse(HttpServletResponse response, String message) throws Exception { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.setHeader("Cache-Control", "no-cache"); + + Map errorResponse = new HashMap<>(); + errorResponse.put("error", OAuthConstants.ErrorCode.INVALID_TOKEN); + errorResponse.put("error_description", message); + errorResponse.put("timestamp", System.currentTimeMillis()); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-web/src/main/resources/application-dev.yaml b/accompany-oauth/accompany-oauth-web/src/main/resources/application-dev.yaml new file mode 100644 index 000000000..e85a86cba --- /dev/null +++ b/accompany-oauth/accompany-oauth-web/src/main/resources/application-dev.yaml @@ -0,0 +1,28 @@ +spring: + # Redis配置 - 开发环境 + redis: + redisson: + config: | + singleServerConfig: + address: "redis://127.0.0.1:6379" + password: null + database: 1 + connectionPoolSize: 5 + connectionMinimumIdleSize: 1 + connectTimeout: 3000 + timeout: 3000 + +# OAuth配置 - 开发环境 +oauth: + jwt: + secret: accompany-oauth-dev-secret-key-2024 + access-token-expiration: 3600 # 开发环境1小时 + refresh-token-expiration: 604800 # 开发环境7天 + +# 日志配置 - 开发环境 +logging: + level: + com.accompany.oauth: DEBUG + org.springframework.web: DEBUG + org.redisson: DEBUG + io.jsonwebtoken: DEBUG \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-web/src/main/resources/application-prod.yaml b/accompany-oauth/accompany-oauth-web/src/main/resources/application-prod.yaml new file mode 100644 index 000000000..020a70d83 --- /dev/null +++ b/accompany-oauth/accompany-oauth-web/src/main/resources/application-prod.yaml @@ -0,0 +1,30 @@ +spring: + # Redis配置 - 生产环境 + redis: + redisson: + config: | + singleServerConfig: + address: "redis://${REDIS_HOST:127.0.0.1}:${REDIS_PORT:6379}" + password: ${REDIS_PASSWORD:null} + database: ${REDIS_DATABASE:0} + connectionPoolSize: 20 + connectionMinimumIdleSize: 5 + connectTimeout: 5000 + timeout: 5000 + retryAttempts: 3 + retryInterval: 2000 + +# OAuth配置 - 生产环境 +oauth: + jwt: + secret: ${JWT_SECRET:accompany-oauth-prod-secret-key-2024-very-secure} + access-token-expiration: ${ACCESS_TOKEN_EXPIRATION:7200} + refresh-token-expiration: ${REFRESH_TOKEN_EXPIRATION:2592000} + +# 日志配置 - 生产环境 +logging: + level: + com.accompany.oauth: INFO + org.springframework.web: WARN + org.redisson: WARN + root: WARN \ No newline at end of file diff --git a/accompany-oauth/accompany-oauth-web/src/main/resources/application.yaml b/accompany-oauth/accompany-oauth-web/src/main/resources/application.yaml new file mode 100644 index 000000000..1ff4a98f9 --- /dev/null +++ b/accompany-oauth/accompany-oauth-web/src/main/resources/application.yaml @@ -0,0 +1,75 @@ +server: + port: 8081 + servlet: + context-path: / + +spring: + application: + name: accompany-oauth + profiles: + active: dev + + # Redis配置 (Redisson) + redis: + redisson: + config: | + singleServerConfig: + address: "redis://127.0.0.1:6379" + password: null + database: 0 + connectionPoolSize: 10 + connectionMinimumIdleSize: 2 + connectTimeout: 3000 + timeout: 3000 + retryAttempts: 3 + retryInterval: 1500 + +# OAuth配置 +oauth: + jwt: + secret: accompany-oauth-secret-key-for-jwt-token-generation-2024 + access-token-expiration: 7200 # 访问令牌有效期(秒) - 2小时 + refresh-token-expiration: 2592000 # 刷新令牌有效期(秒) - 30天 + + client: + default: + client-id: default + client-secret: default-secret + grant-types: password,verify_code,email,openid,refresh_token + scopes: read,write + access-token-validity: 7200 + refresh-token-validity: 2592000 + +# 日志配置 +logging: + level: + com.accompany.oauth: DEBUG + org.springframework.web: INFO + org.redisson: INFO + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: logs/accompany-oauth.log + max-size: 100MB + max-history: 30 + +# 管理端点配置 +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus + endpoint: + health: + show-details: when_authorized + server: + port: 8082 + +# Swagger文档配置 +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + operationsSorter: method \ No newline at end of file diff --git a/pom.xml b/pom.xml index 55f02c867..b921e620a 100644 --- a/pom.xml +++ b/pom.xml @@ -15,6 +15,7 @@ accompany-base accompany-business accompany-oauth2 + accompany-oauth accompany-scheduler accompany-mq