From a7a814fbf295dbfb3f4723e3ad3db62c0ce278d0 Mon Sep 17 00:00:00 2001 From: Yui Date: Mon, 22 Jun 2026 11:17:15 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=AE=8C=E5=96=84=20OAuth=20=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E8=8C=83=E5=9B=B4=E7=94=A8=E6=88=B7=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + API.md | 137 +++++- .../symphony/processor/OpenIdProcessor.java | 457 +++++++++++++++--- .../symphony/processor/UserProcessor.java | 112 ----- .../org/b3log/symphony/util/OpenIdUtil.java | 57 +++ .../skins/classic/mobile/verify/openid.ftl | 34 +- .../skins/classic/pc/verify/openid.ftl | 34 +- 7 files changed, 626 insertions(+), 206 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5fee14f42..0b27de7c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,7 @@ - `BeforeRequestHandler` 获取当前用户优先用 `UserQueryService#getCurrentUser(request)`(底层 `Sessions.currentUser(request)`);`Sessions.getUser()` 仅读取 ThreadLocal,未 `Sessions.setUser(...)` 前通常为 `null`。 - `LoginCheckMidware#handle`:未登录统一 401(特殊 URI `/gen` 返回空 SVG);支持 `Sessions` 与 `apiKey` 两种登录态来源。 - 新接口若需“页面登录态 + apiKey 调用”双兼容,路由层优先挂 `loginCheck::handle`,处理方法再读取 `context.attr(User.USER)`;避免只挂 `permission` 导致拿不到当前用户对象。 +- OAuth/OpenID 用户资源链路在 `OpenIdProcessor`:`/openid/login` 通过 `fishpi.scope` 请求 `profile.read/points.read/articles.read`,`/openid/verify` 成功后返回短期 Bearer `access_token`;`/openid/user/*` 资源接口只认该 token,不挂 `loginCheck`、不接收 `apiKey`、不支持 `userId` 代查。 - 文章页实时链路走 `ArticleChannel`:新增评论仍是 `type=comment`,评论 reaction 增量走 `type=commentReaction`,帖子本体 reaction 增量走 `type=articleReaction`;联调这类 Java 推送改动时,前端强刷还不够,必须让用户重启或重新编译服务端。 - `AnonymousViewCheckMidware#handle`:匿名访问触发验证码(2 小时首次访问 + 每 5 次访问),并结合 `anonymous.viewSkips`、文章匿名开关、匿名访问次数 Cookie 限制。 - `Server` 启动逻辑:`DEVELOPMENT` 模式会关闭 `Firewall` 与 `AnonymousViewCheck`(验证码盾),联调时不要误判“线上无校验”。 diff --git a/API.md b/API.md index ecf2e3695..fb345aaea 100644 --- a/API.md +++ b/API.md @@ -479,26 +479,91 @@ curl --location --request POST 'https://fishpi.cn/report' \ `GET /user/{用户名}/point` -### 查询积分记录 +### 查询用户勋章 + +`GET /user/{用户名}/medal` + +## OAuth 用户资源 -`GET /api/user/points` +### OAuth 授权范围 -分页查询当前用户积分记录。管理员可通过 `userId` 查询指定用户。 +`GET /openid/login` + +第三方登录时可通过 `fishpi.scope` 请求用户资源权限。用户授权后,`POST /openid/verify` 成功响应会追加 `access_token`。 请求: -| Key | 说明 | 示例 | -| ------ | ----------------------- | -------------------------------- | -| apiKey | 通用密钥 | oXTQTD4ljryXoIxa1lySgEl6aObrIhSS | -| p | 页码,默认 1 | 1 | -| size | 每页数量,默认 20,最大 200 | 20 | -| userId | 用户 oId,仅管理员可用 | 1659430635383 | +| Key | 说明 | 示例 | +| ------------ | ---------------------------- | ---------------------------- | +| fishpi.scope | 授权范围,空格或逗号分隔 | profile.read points.read | 请求示例: ```bash -curl --location --request GET 'https://fishpi.cn/api/user/points?apiKey=oXTQTD4ljryXoIxa1lySgEl6aObrIhSS&p=1&size=20' \ ---header 'User-Agent: Mozilla/5.0' +curl --location --request GET 'https://fishpi.cn/openid/login?openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&openid.mode=checkid_setup&openid.return_to=https%3A%2F%2Fexample.com%2Fcallback&openid.realm=https%3A%2F%2Fexample.com&openid.claimed_id=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&openid.identity=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0%2Fidentifier_select&fishpi.scope=profile.read%20points.read' +``` + +响应: + +| Key | 说明 | 示例 | +| ------------ | ---------------------------- | ---------------------------- | +| scope | 用户最终授权范围 | profile.read points.read | +| token_type | 令牌类型 | Bearer | +| access_token | OAuth 用户资源访问令牌 | xxx | +| expires_in | 有效期,单位秒 | 1800 | + +> `profile.read` 为基础权限;站点请求的权限为必选,用户可额外勾选更多权限。 + +### 查询 OAuth 用户信息 + +`GET /openid/user/profile` + +查询当前 OAuth 授权用户的基础信息。需要 `profile.read` 权限。 + +请求: + +| Key | 说明 | 示例 | +| ------------- | ---------------- | ------------ | +| Authorization | Bearer 访问令牌 | Bearer xxx | + +请求示例: + +```bash +curl --location --request GET 'https://fishpi.cn/openid/user/profile' \ +--header 'Authorization: Bearer xxx' +``` + +响应: + +| Key | 说明 | 示例 | +| ---------------- | -------------------- | ----------------------------- | +| code | 0 为成功,-1 为失败 | 0 | +| msg | 错误消息 | | +| data | 响应数据 | | +| - userId | 用户 oId | 1659430635383 | +| - userName | 用户名 | admin | +| - userNickname | 昵称 | 管理员 | +| - userAvatarURL | 头像 | https://file.fishpi.cn/a.png | + +### 查询 OAuth 用户积分记录 + +`GET /openid/user/points` + +分页查询当前 OAuth 授权用户的积分余额和积分记录。需要 `points.read` 权限。 + +请求: + +| Key | 说明 | 示例 | +| ------------- | ---------------------------- | ---------- | +| Authorization | Bearer 访问令牌 | Bearer xxx | +| p | 页码,默认 1 | 1 | +| size | 每页数量,默认 20,最大 200 | 20 | + +请求示例: + +```bash +curl --location --request GET 'https://fishpi.cn/openid/user/points?p=1&size=20' \ +--header 'Authorization: Bearer xxx' ``` 响应: @@ -509,6 +574,7 @@ curl --location --request GET 'https://fishpi.cn/api/user/points?apiKey=oXTQTD4l | msg | 错误消息 | | | data | 响应数据 | | | - userId | 用户 oId | 1659430635383 | +| - userPoint | 当前积分 | 183939 | | - records | 积分记录列表 | | | -- oId | 记录 oId | 1760000000000 | | -- fromId | 支出用户 oId | 1659430635383 | @@ -529,9 +595,54 @@ curl --location --request GET 'https://fishpi.cn/api/user/points?apiKey=oXTQTD4l | -- paginationPageCount | 总页数 | 5 | | -- paginationPageNums | 页码列表 | [1,2,3,4,5] | -### 查询用户勋章 +### 查询 OAuth 用户发帖记录 -`GET /user/{用户名}/medal` +`GET /openid/user/articles` + +分页查询当前 OAuth 授权用户的公开发帖记录。需要 `articles.read` 权限。 + +请求: + +| Key | 说明 | 示例 | +| ------------- | ---------------------------- | ---------- | +| Authorization | Bearer 访问令牌 | Bearer xxx | +| p | 页码,默认 1 | 1 | +| size | 每页数量,默认 20,最大 100 | 20 | + +请求示例: + +```bash +curl --location --request GET 'https://fishpi.cn/openid/user/articles?p=1&size=20' \ +--header 'Authorization: Bearer xxx' +``` + +响应: + +| Key | 说明 | 示例 | +| -------------------------------- | -------------------- | ------------- | +| code | 0 为成功,-1 为失败 | 0 | +| msg | 错误消息 | | +| data | 响应数据 | | +| - userId | 用户 oId | 1659430635383 | +| - articles | 发帖记录列表 | | +| -- oId | 帖子 oId | 1760000000000 | +| -- articleTitle | 标题 | 摸鱼 | +| -- articlePermalink | 链接 | /article/1760000000000 | +| -- articleTags | 标签 | 摸鱼,生活 | +| -- articleCreateTime | 创建时间 | 1760000000000 | +| -- articleUpdateTime | 更新时间 | 1760000000000 | +| -- articleCommentCount | 回帖数 | 3 | +| -- articleViewCount | 浏览数 | 100 | +| -- articleType | 帖子类型 | 0 | +| -- articlePerfect | 是否优选 | 0 | +| - pagination | 分页信息 | | +| -- paginationCurrentPageNum | 当前页 | 1 | +| -- paginationPageSize | 每页数量 | 20 | +| -- paginationRecordCount | 总记录数 | 100 | +| -- paginationPageCount | 总页数 | 5 | +| -- paginationPageNums | 页码列表 | [1,2,3,4,5] | + +> 发帖记录只返回公开、非匿名、未删除发帖,不包含正文、草稿、IP、UA。 ## 通知 diff --git a/src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java b/src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java index 0ae4dfded..db5e35774 100644 --- a/src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java +++ b/src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java @@ -18,25 +18,37 @@ */ package org.b3log.symphony.processor; +import org.apache.commons.lang.StringUtils; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.b3log.latke.Keys; import org.b3log.latke.Latkes; +import org.b3log.latke.http.Dispatcher; import org.b3log.latke.http.Request; import org.b3log.latke.http.RequestContext; import org.b3log.latke.http.renderer.AbstractFreeMarkerRenderer; import org.b3log.latke.ioc.BeanManager; import org.b3log.latke.ioc.Inject; import org.b3log.latke.ioc.Singleton; +import org.b3log.latke.model.Pagination; +import org.b3log.latke.util.Paginator; +import org.b3log.symphony.model.Article; +import org.b3log.symphony.model.Common; +import org.b3log.symphony.model.Pointtransfer; +import org.b3log.symphony.model.UserExt; import org.b3log.symphony.processor.middleware.CSRFMidware; import org.b3log.symphony.processor.middleware.LoginCheckMidware; -import org.b3log.latke.http.Dispatcher; import org.b3log.symphony.service.ActivityMgmtService; +import org.b3log.symphony.service.ArticleQueryService; import org.b3log.symphony.service.DataModelService; +import org.b3log.symphony.service.PointtransferQueryService; +import org.b3log.symphony.service.UserQueryService; import org.b3log.symphony.util.OpenIdUtil; import org.b3log.symphony.util.Sessions; import org.b3log.symphony.util.StatusCodes; import org.b3log.symphony.util.Symphonys; +import org.json.JSONArray; import org.json.JSONObject; import java.net.URI; @@ -67,13 +79,37 @@ public class OpenIdProcessor { private static final String OPENID_SIGNED_KEY = "openid.signed"; private static final String OPENID_SIGNED = "op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle"; private static final String OPENID_SIG_KEY = "openid.sig"; + private static final String FISHPI_SCOPE_KEY = "fishpi.scope"; + private static final String FISHPI_AUTH_REQUEST_ID_KEY = "fishpi.authRequestId"; + private static final String SCOPE_PROFILE_READ = "profile.read"; + private static final String SCOPE_POINTS_READ = "points.read"; + private static final String SCOPE_ARTICLES_READ = "articles.read"; + private static final long OPENID_TMP_EXPIRES = 5 * 60 * 1000L; + private static final long OPENID_ACCESS_TOKEN_EXPIRES = 30 * 60 * 1000L; + private static final List SCOPE_DEFINITIONS = Arrays.asList( + new ScopeDefinition(SCOPE_PROFILE_READ, "个人信息"), + new ScopeDefinition(SCOPE_POINTS_READ, "积分信息"), + new ScopeDefinition(SCOPE_ARTICLES_READ, "发帖信息") + ); + private static final Set ALL_SCOPES = new LinkedHashSet<>(Arrays.asList( + SCOPE_PROFILE_READ, SCOPE_POINTS_READ, SCOPE_ARTICLES_READ + )); + + private static final Map authRequestMap = Collections.synchronizedMap(new LinkedHashMap<>()); + private static final Map respNonceMap = Collections.synchronizedMap(new LinkedHashMap<>()); - private static final Map respNonceMap = new LinkedHashMap<>(); + @Inject + private DataModelService dataModelService; + @Inject + private UserQueryService userQueryService; @Inject - private DataModelService dataModelService; + private PointtransferQueryService pointtransferQueryService; + + @Inject + private ArticleQueryService articleQueryService; public static void register() { final BeanManager beanManager = BeanManager.getInstance(); @@ -85,6 +121,9 @@ public static void register() { Dispatcher.get("/openid/login", openIdProcessor::showLoginForm, loginCheck::handle,csrfMidware::fill); Dispatcher.post("/openid/confirm", openIdProcessor::confirm, loginCheck::handle,csrfMidware::fill); Dispatcher.post("/openid/verify", openIdProcessor::verify, csrfMidware::fill); + Dispatcher.get("/openid/user/profile", openIdProcessor::getProfile); + Dispatcher.get("/openid/user/points", openIdProcessor::getPoints); + Dispatcher.get("/openid/user/articles", openIdProcessor::getArticles); // 开启定时任务,清理过期的nonce Symphonys.SCHEDULED_EXECUTOR_SERVICE.scheduleAtFixedRate(() -> { @@ -111,6 +150,10 @@ public void showLoginForm(final RequestContext context) { String realm = context.param(OPENID_REALM_KEY); String identity = context.param(OPENID_IDENTITY_KEY); String claimed_id = context.param(OPENID_CLAIMED_ID_KEY); + final Set requestedScopes = parseScopes(context.param(FISHPI_SCOPE_KEY)); + if (requestedScopes.isEmpty()) { + requestedScopes.add(SCOPE_PROFILE_READ); + } if(ns==null||mode==null||return_to==null||realm==null||identity==null||claimed_id==null){ context.sendError(500); @@ -149,6 +192,7 @@ public void showLoginForm(final RequestContext context) { } }catch (Exception e){ context.sendError(500); + return; } // 判断return_to 是否在 realm 下 @@ -161,10 +205,14 @@ public void showLoginForm(final RequestContext context) { String realmName = realm.replaceFirst("^(https?://)?([^/]+).*", "$2"); // String realmName = realm.substring(realm.lastIndexOf("/")+1); + final String authRequestId = UUID.randomUUID().toString().replace("-", ""); + authRequestMap.put(authRequestId, new AuthRequest(ns, mode, return_to, realm, identity, claimed_id, requestedScopes)); renderer.setTemplateName("verify/openid.ftl"); final Map dataModel = renderer.getDataModel(); dataModel.put("realmName",realmName); + dataModel.put("fishpi_auth_request_id", authRequestId); + dataModel.put("fishpiScopes", buildScopeView(requestedScopes)); dataModel.put("openid_ns",ns); dataModel.put("openid_mode",mode); dataModel.put("openid_identity",identity); @@ -183,58 +231,30 @@ public void showLoginForm(final RequestContext context) { public void confirm(final RequestContext context){ Request request = context.getRequest(); - final String ns = request.getParameter(OPENID_NS_KEY); - final String mode = request.getParameter(OPENID_MODE_KEY); - final String return_to = request.getParameter(OPENID_RETURN_TO_KEY); - final String realm = request.getParameter(OPENID_REALM_KEY); - final String identity = request.getParameter(OPENID_IDENTITY_KEY); - final String claimed_id = request.getParameter(OPENID_CLAIMED_ID_KEY); + final String authRequestId = request.getParameter(FISHPI_AUTH_REQUEST_ID_KEY); + final AuthRequest authRequest = authRequestMap.remove(authRequestId); - if(ns==null||mode==null||return_to==null||realm==null||identity==null||claimed_id==null){ - redirectWithError(context,return_to); - return; - } - // 先验证参数是否正确,不正确就回首页 - if(!OPENID_NS.equals(ns)){ - redirectWithError(context,return_to); - return; - } - if(!OPENID_MODE_CHECKID.equals(mode)){ - redirectWithError(context,return_to); + if (null == authRequest) { + redirectWithError(context, null); return; } - if(!OPENID_IDENTITY.equals(identity)){ - redirectWithError(context,return_to); - return; - } - if(!OPENID_CLAIMED_ID.equals(claimed_id)){ - redirectWithError(context,return_to); + + if (authRequest.isExpired()) { + redirectWithError(context, authRequest.returnTo); return; } - // 判断return url 是否是https 如果是localhost 和 127.0.0.1 也可以的 - try{ - URI returnTo = new URI(return_to); - String scheme = returnTo.getScheme(); - String host = returnTo.getHost(); - - boolean isAllowed = ("https".equalsIgnoreCase(scheme)) || - ("localhost".equals(host)) || - ("127.0.0.1".equals(host)); - if (!isAllowed) { - redirectWithError(context, return_to); - return; - } - }catch (Exception e){ - redirectWithError(context,return_to); + if ("true".equals(request.getParameter("cancel"))) { + redirectWithError(context, authRequest.returnTo); + return; } - - // 判断return_to 是否在 realm 下 - if(!return_to.startsWith(realm)){ - redirectWithError(context,return_to); + final Set grantedScopes = parseGrantedScopes(request); + if (!grantedScopes.containsAll(authRequest.requestedScopes) || grantedScopes.isEmpty()) { + redirectWithError(context, authRequest.returnTo); return; } + Map result = new LinkedHashMap<>(); try { // 取得当前登录的用户 @@ -248,13 +268,13 @@ public void confirm(final RequestContext context){ result.put(OPENID_ASSOC_HANDLE_KEY, OPENID_ASSOC_HANDLE); String nonce = OpenIdUtil.generateNonce(); result.put(OPENID_RESPONSE_NONCE_KEY, nonce); - respNonceMap.put(nonce,userId); - result.put(OPENID_RETURN_TO_KEY,return_to); + respNonceMap.put(nonce, new AuthResult(userId, authRequest.realm, grantedScopes)); + result.put(OPENID_RETURN_TO_KEY, authRequest.returnTo); result.put(OPENID_SIGNED_KEY,OPENID_SIGNED); result.put(OPENID_SIG_KEY, OpenIdUtil.sign(result)); - StringBuilder redirect = new StringBuilder(return_to); - if (!return_to.contains("?")) { + StringBuilder redirect = new StringBuilder(authRequest.returnTo); + if (!authRequest.returnTo.contains("?")) { redirect.append("?"); } else { redirect.append("&"); @@ -270,7 +290,7 @@ public void confirm(final RequestContext context){ context.sendRedirect(redirect.toString()); }catch (Exception e){ LOGGER.log(Level.ERROR,e.getMessage(),e); - redirectWithError(context,return_to); + redirectWithError(context, authRequest.returnTo); } } @@ -306,7 +326,7 @@ public void verify(final RequestContext context){ final String response_nonce = requestJSONObject.optString(OPENID_RESPONSE_NONCE_KEY); final String assoc_handle = requestJSONObject.optString(OPENID_ASSOC_HANDLE_KEY); final String sig = requestJSONObject.optString(OPENID_SIG_KEY); - String userId =null; + AuthResult authResult = null; if(response_nonce!=null){ // 先验证时间 @@ -334,7 +354,7 @@ public void verify(final RequestContext context){ return; } - userId = respNonceMap.get(response_nonce); + authResult = respNonceMap.get(response_nonce); respNonceMap.remove(response_nonce); if(ns==null||mode==null||op_endpoint==null||return_to==null||identity==null||claimed_id==null||response_nonce==null||assoc_handle==null){ @@ -396,6 +416,7 @@ public void verify(final RequestContext context){ }catch (Exception e){ //context.renderJSON(StatusCodes.ERR).renderMsg("验证失败10"); sendVerifyResult(context,false); + return; } if(!identity.equals(claimed_id)){ @@ -403,50 +424,356 @@ public void verify(final RequestContext context){ sendVerifyResult(context,false); return; } - if(userId==null){ + if(null == authResult || authResult.userId == null){ // context.renderJSON(StatusCodes.ERR).renderMsg("验证失败12"); sendVerifyResult(context,false); return; } String requestUserId = identity.substring(identity.lastIndexOf("/")+1); - if(!requestUserId.equals(userId)){ + if(!requestUserId.equals(authResult.userId)){ // context.renderJSON(StatusCodes.ERR).renderMsg("验证失败13"); sendVerifyResult(context,false); return; } // context.renderJSON(StatusCodes.SUCC).renderMsg("验证成功"); - sendVerifyResult(context,true); + sendVerifyResult(context,true, authResult); } private void sendVerifyResult(final RequestContext context,Boolean result){ + sendVerifyResult(context, result, null); + } + + private void sendVerifyResult(final RequestContext context,Boolean result, final AuthResult authResult){ // 根据result纯文本返回 if(result){ - context.sendString("ns:http://specs.openid.net/auth/2.0\nis_valid:true\n"); + final StringBuilder builder = new StringBuilder("ns:http://specs.openid.net/auth/2.0\nis_valid:true\n"); + if (null != authResult && !authResult.scopes.isEmpty()) { + try { + final long expiresAt = System.currentTimeMillis() + OPENID_ACCESS_TOKEN_EXPIRES; + builder.append("scope:").append(String.join(" ", authResult.scopes)).append("\n"); + builder.append("token_type:Bearer\n"); + builder.append("access_token:") + .append(OpenIdUtil.generateAccessToken(authResult.userId, authResult.scopes, authResult.realm, expiresAt)) + .append("\n"); + builder.append("expires_in:").append(OPENID_ACCESS_TOKEN_EXPIRES / 1000).append("\n"); + } catch (final Exception e) { + LOGGER.log(Level.ERROR, "Generates OpenID access token failed", e); + context.sendString("ns:http://specs.openid.net/auth/2.0\nis_valid:false\n"); + return; + } + } + context.sendString(builder.toString()); }else{ context.sendString("ns:http://specs.openid.net/auth/2.0\nis_valid:false\n"); } } + public void getProfile(final RequestContext context) { + final JSONObject user = requireOpenIdUser(context, SCOPE_PROFILE_READ); + if (null == user) { + return; + } + + final JSONObject data = new JSONObject() + .put("userId", user.optString(Keys.OBJECT_ID)) + .put("userName", user.optString(org.b3log.latke.model.User.USER_NAME)) + .put("userNickname", user.optString(UserExt.USER_NICKNAME)) + .put("userAvatarURL", user.optString(UserExt.USER_AVATAR_URL)); + renderSucc(context, data); + } + + public void getPoints(final RequestContext context) { + final JSONObject user = requireOpenIdUser(context, SCOPE_POINTS_READ); + if (null == user) { + return; + } + + final String userId = user.optString(Keys.OBJECT_ID); + final int pageNum = getPositiveIntParam(context, "p", 1); + final int pageSize = Math.min(getPositiveIntParam(context, "size", Symphonys.USER_HOME_LIST_CNT), 200); + final JSONObject userPointsResult = pointtransferQueryService.getUserPoints(userId, pageNum, pageSize); + if (null == userPointsResult) { + renderError(context, "查询失败"); + return; + } + + final int recordCount = userPointsResult.optInt(Pagination.PAGINATION_RECORD_COUNT); + final JSONObject data = new JSONObject() + .put("userId", userId) + .put("userPoint", user.optInt(UserExt.USER_POINT)) + .put("records", buildPointRecords(userPointsResult.opt(Keys.RESULTS))) + .put(Pagination.PAGINATION, buildPagination(pageNum, pageSize, recordCount, Symphonys.USER_HOME_LIST_WIN_SIZE)); + renderSucc(context, data); + } + + public void getArticles(final RequestContext context) { + final JSONObject user = requireOpenIdUser(context, SCOPE_ARTICLES_READ); + if (null == user) { + return; + } + + final String userId = user.optString(Keys.OBJECT_ID); + final int pageNum = getPositiveIntParam(context, "p", 1); + final int pageSize = Math.min(getPositiveIntParam(context, "size", Symphonys.ARTICLE_LIST_CNT), 100); + final List userArticles = articleQueryService.getUserArticles(userId, Article.ARTICLE_ANONYMOUS_C_PUBLIC, pageNum, pageSize); + int recordCount = 0; + if (!userArticles.isEmpty()) { + recordCount = userArticles.get(0).optInt(Pagination.PAGINATION_RECORD_COUNT); + } + + final JSONObject data = new JSONObject() + .put("userId", userId) + .put("articles", buildArticleRecords(userArticles)) + .put(Pagination.PAGINATION, buildPagination(pageNum, pageSize, recordCount, Symphonys.USER_HOME_LIST_WIN_SIZE)); + renderSucc(context, data); + } + + private JSONObject requireOpenIdUser(final RequestContext context, final String requiredScope) { + String authorization = StringUtils.trim(context.header("Authorization")); + if (StringUtils.isBlank(authorization) || !StringUtils.startsWithIgnoreCase(authorization, "Bearer ")) { + renderAccessDenied(context); + return null; + } + + final String token = StringUtils.trim(authorization.substring("Bearer ".length())); + final JSONObject payload = OpenIdUtil.parseAccessToken(token); + if (null == payload) { + renderAccessDenied(context); + return null; + } + + final Set scopes = parseScopes(payload.optString("scope")); + if (!scopes.contains(requiredScope)) { + renderAccessDenied(context); + return null; + } + + final JSONObject user = userQueryService.getUser(payload.optString("userId")); + if (null == user || UserExt.USER_STATUS_C_VALID != user.optInt(UserExt.USER_STATUS)) { + renderAccessDenied(context); + return null; + } + + return user; + } + + private Set parseScopes(final String scopeText) { + final Set ret = new LinkedHashSet<>(); + if (StringUtils.isBlank(scopeText)) { + return ret; + } + + final String[] scopes = scopeText.split("[,\\s]+"); + for (final String scope : scopes) { + final String normalized = StringUtils.trim(scope); + if (ALL_SCOPES.contains(normalized)) { + ret.add(normalized); + } + } + + return ret; + } + + private Set parseGrantedScopes(final Request request) { + final Set ret = new LinkedHashSet<>(); + for (final String scope : ALL_SCOPES) { + if (StringUtils.isNotBlank(request.getParameter(FISHPI_SCOPE_KEY + "." + scope))) { + ret.add(scope); + } + } + + return ret; + } + + private List> buildScopeView(final Set requestedScopes) { + final List> ret = new ArrayList<>(); + for (final ScopeDefinition definition : SCOPE_DEFINITIONS) { + final Map item = new LinkedHashMap<>(); + item.put("key", definition.key); + item.put("label", definition.label); + item.put("requested", requestedScopes.contains(definition.key)); + ret.add(item); + } + + return ret; + } + + private JSONArray buildPointRecords(final Object pointsValue) { + final JSONArray records = new JSONArray(); + if (pointsValue instanceof JSONArray) { + final JSONArray points = (JSONArray) pointsValue; + for (int i = 0; i < points.length(); i++) { + appendPointRecord(records, points.opt(i)); + } + return records; + } + + if (pointsValue instanceof List) { + for (final Object point : (List) pointsValue) { + appendPointRecord(records, point); + } + } + + return records; + } + + private void appendPointRecord(final JSONArray records, final Object item) { + if (!(item instanceof JSONObject)) { + return; + } + + final JSONObject point = (JSONObject) item; + records.put(new JSONObject() + .put(Keys.OBJECT_ID, point.optString(Keys.OBJECT_ID)) + .put(Pointtransfer.FROM_ID, point.optString(Pointtransfer.FROM_ID)) + .put(Pointtransfer.TO_ID, point.optString(Pointtransfer.TO_ID)) + .put(Pointtransfer.SUM, point.optInt(Pointtransfer.SUM)) + .put(Pointtransfer.TYPE, point.optInt(Pointtransfer.TYPE)) + .put(Pointtransfer.TIME, point.optLong(Pointtransfer.TIME)) + .put(Pointtransfer.DATA_ID, point.optString(Pointtransfer.DATA_ID)) + .put(Pointtransfer.MEMO, point.optString(Pointtransfer.MEMO)) + .put(Common.OPERATION, point.optString(Common.OPERATION)) + .put(Common.BALANCE, point.optInt(Common.BALANCE)) + .put(Common.DISPLAY_TYPE, point.optString(Common.DISPLAY_TYPE)) + .put(Common.DESCRIPTION, point.optString(Common.DESCRIPTION))); + } + + private JSONArray buildArticleRecords(final List articles) { + final JSONArray records = new JSONArray(); + for (final JSONObject article : articles) { + records.put(new JSONObject() + .put(Keys.OBJECT_ID, article.optString(Keys.OBJECT_ID)) + .put(Article.ARTICLE_TITLE, article.optString(Article.ARTICLE_TITLE)) + .put(Article.ARTICLE_PERMALINK, article.optString(Article.ARTICLE_PERMALINK)) + .put(Article.ARTICLE_TAGS, article.optString(Article.ARTICLE_TAGS)) + .put(Article.ARTICLE_CREATE_TIME, article.optLong(Article.ARTICLE_CREATE_TIME)) + .put(Article.ARTICLE_UPDATE_TIME, article.optLong(Article.ARTICLE_UPDATE_TIME)) + .put(Article.ARTICLE_COMMENT_CNT, article.optInt(Article.ARTICLE_COMMENT_CNT)) + .put(Article.ARTICLE_VIEW_CNT, article.optInt(Article.ARTICLE_VIEW_CNT)) + .put(Article.ARTICLE_TYPE, article.optInt(Article.ARTICLE_TYPE)) + .put(Article.ARTICLE_PERFECT, article.optInt(Article.ARTICLE_PERFECT))); + } + + return records; + } + + private JSONObject buildPagination(final int pageNum, final int pageSize, final int recordCount, final int windowSize) { + final int pageCount = (int) Math.ceil(recordCount / (double) pageSize); + + return new JSONObject() + .put(Pagination.PAGINATION_CURRENT_PAGE_NUM, pageNum) + .put(Pagination.PAGINATION_PAGE_SIZE, pageSize) + .put(Pagination.PAGINATION_RECORD_COUNT, recordCount) + .put(Pagination.PAGINATION_PAGE_COUNT, pageCount) + .put(Pagination.PAGINATION_PAGE_NUMS, Paginator.paginate(pageNum, pageSize, pageCount, windowSize)); + } + + private int getPositiveIntParam(final RequestContext context, final String key, final int defaultValue) { + final String value = StringUtils.trim(context.param(key)); + if (StringUtils.isBlank(value)) { + return defaultValue; + } + + try { + final int ret = Integer.parseInt(value); + return ret > 0 ? ret : defaultValue; + } catch (final NumberFormatException e) { + return defaultValue; + } + } + + private void renderSucc(final RequestContext context, final JSONObject data) { + context.renderJSON(new JSONObject() + .put(Keys.CODE, StatusCodes.SUCC) + .put(Keys.MSG, "") + .put(Common.DATA, data)); + } + + private void renderAccessDenied(final RequestContext context) { + renderError(context, "无权限"); + } + + private void renderError(final RequestContext context, final String msg) { + context.renderJSON(new JSONObject() + .put(Keys.CODE, StatusCodes.ERR) + .put(Keys.MSG, msg) + .put(Common.DATA, new JSONObject())); + } + + private static final class ScopeDefinition { + private final String key; + private final String label; + + private ScopeDefinition(final String key, final String label) { + this.key = key; + this.label = label; + } + } + + private static final class AuthRequest { + private final String returnTo; + private final String realm; + private final Set requestedScopes; + private final long createdAt = System.currentTimeMillis(); + + private AuthRequest(final String ns, final String mode, final String returnTo, final String realm, + final String identity, final String claimedId, final Set requestedScopes) { + this.returnTo = returnTo; + this.realm = realm; + this.requestedScopes = new LinkedHashSet<>(requestedScopes); + } + + private boolean isExpired() { + return System.currentTimeMillis() - createdAt > OPENID_TMP_EXPIRES; + } + } + + private static final class AuthResult { + private final String userId; + private final String realm; + private final Set scopes; + + private AuthResult(final String userId, final String realm, final Set scopes) { + this.userId = userId; + this.realm = realm; + this.scopes = new LinkedHashSet<>(scopes); + } + } + // 清理过期的nonce private static void clearExpiredNonce(){ long now = System.currentTimeMillis(); List toRemove = new ArrayList<>(); - for (String nonce : respNonceMap.keySet()) { - try { - Date nonceTime = OpenIdUtil.extractNonceTimestamp(nonce); - long delta = Math.abs(now - nonceTime.getTime()); - if (delta > 5 * 60 * 1000) { + synchronized (respNonceMap) { + for (String nonce : respNonceMap.keySet()) { + try { + Date nonceTime = OpenIdUtil.extractNonceTimestamp(nonce); + long delta = Math.abs(now - nonceTime.getTime()); + if (delta > OPENID_TMP_EXPIRES) { + toRemove.add(nonce); + } + } catch (Exception e) { toRemove.add(nonce); } - } catch (Exception e) { - toRemove.add(nonce); + } + for (String nonce : toRemove) { + respNonceMap.remove(nonce); } } - for (String nonce : toRemove) { - respNonceMap.remove(nonce); + + synchronized (authRequestMap) { + final List requestIds = new ArrayList<>(); + for (Map.Entry entry : authRequestMap.entrySet()) { + if (entry.getValue().isExpired()) { + requestIds.add(entry.getKey()); + } + } + for (final String requestId : requestIds) { + authRequestMap.remove(requestId); + } } } diff --git a/src/main/java/org/b3log/symphony/processor/UserProcessor.java b/src/main/java/org/b3log/symphony/processor/UserProcessor.java index 8f3c520d0..06dbbf03d 100644 --- a/src/main/java/org/b3log/symphony/processor/UserProcessor.java +++ b/src/main/java/org/b3log/symphony/processor/UserProcessor.java @@ -256,7 +256,6 @@ public static void register() { Dispatcher.post("/user/edit/points", userProcessor::adjustPoint); Dispatcher.post("/user/edit/notification", userProcessor::sendSystemNotification); Dispatcher.post("/user/identify", userProcessor::submitIdentify, loginCheck::handle); - Dispatcher.get("/api/user/points", userProcessor::getUserPointRecords, loginCheck::handle); Dispatcher.get("/api/user/{userName}/articles", userProcessor::userArticles, loginCheck::handle); Dispatcher.get("/api/user/{userName}/breezemoons", userProcessor::userBreezemoons, loginCheck::handle); } @@ -1667,117 +1666,6 @@ public void showHomePoints(final RequestContext context) { dataModel.put(Common.TYPE, "points"); } - /** - * Gets user point records. - * - * @param context the specified context - */ - public void getUserPointRecords(final RequestContext context) { - final JSONObject currentUser = (JSONObject) context.attr(User.USER); - if (null == currentUser) { - context.sendError(401); - return; - } - - final boolean isAdmin = Role.ROLE_ID_C_ADMIN.equals(currentUser.optString(User.USER_ROLE)); - final String currentUserId = currentUser.optString(Keys.OBJECT_ID); - String targetUserId = StringUtils.trim(context.param("userId")); - if (StringUtils.isBlank(targetUserId)) { - targetUserId = currentUserId; - } else if (!StringUtils.isNumeric(targetUserId) || targetUserId.length() > 19) { - renderUserPointRecordsError(context, "参数错误"); - return; - } - - if (!isAdmin && !currentUserId.equals(targetUserId)) { - renderUserPointRecordsError(context, "无权限"); - return; - } - - final JSONObject targetUser = userQueryService.getUser(targetUserId); - if (null == targetUser) { - renderUserPointRecordsError(context, "用户不存在"); - return; - } - - final int pageNum = getPositiveIntParam(context, "p", 1); - final int pageSize = Math.min(getPositiveIntParam(context, "size", Symphonys.USER_HOME_LIST_CNT), 200); - final int windowSize = Symphonys.USER_HOME_LIST_WIN_SIZE; - - final JSONObject userPointsResult = pointtransferQueryService.getUserPoints(targetUserId, pageNum, pageSize); - if (null == userPointsResult) { - renderUserPointRecordsError(context, "查询失败"); - return; - } - - final int recordCount = userPointsResult.optInt(Pagination.PAGINATION_RECORD_COUNT); - final int pageCount = (int) Math.ceil(recordCount / (double) pageSize); - final List pageNums = Paginator.paginate(pageNum, pageSize, pageCount, windowSize); - - final JSONArray records = new JSONArray(); - final JSONArray points = userPointsResult.optJSONArray(Keys.RESULTS); - if (null != points) { - for (int i = 0; i < points.length(); i++) { - final Object item = points.opt(i); - if (!(item instanceof JSONObject)) { - continue; - } - - final JSONObject point = (JSONObject) item; - records.put(new JSONObject() - .put(Keys.OBJECT_ID, point.optString(Keys.OBJECT_ID)) - .put(Pointtransfer.FROM_ID, point.optString(Pointtransfer.FROM_ID)) - .put(Pointtransfer.TO_ID, point.optString(Pointtransfer.TO_ID)) - .put(Pointtransfer.SUM, point.optInt(Pointtransfer.SUM)) - .put(Pointtransfer.TYPE, point.optInt(Pointtransfer.TYPE)) - .put(Pointtransfer.TIME, point.optLong(Pointtransfer.TIME)) - .put(Pointtransfer.DATA_ID, point.optString(Pointtransfer.DATA_ID)) - .put(Pointtransfer.MEMO, point.optString(Pointtransfer.MEMO)) - .put(Common.OPERATION, point.optString(Common.OPERATION)) - .put(Common.BALANCE, point.optInt(Common.BALANCE)) - .put(Common.DISPLAY_TYPE, point.optString(Common.DISPLAY_TYPE)) - .put(Common.DESCRIPTION, point.optString(Common.DESCRIPTION))); - } - } - - final JSONObject pagination = new JSONObject() - .put(Pagination.PAGINATION_CURRENT_PAGE_NUM, pageNum) - .put(Pagination.PAGINATION_PAGE_SIZE, pageSize) - .put(Pagination.PAGINATION_RECORD_COUNT, recordCount) - .put(Pagination.PAGINATION_PAGE_COUNT, pageCount) - .put(Pagination.PAGINATION_PAGE_NUMS, pageNums); - final JSONObject data = new JSONObject() - .put("userId", targetUserId) - .put("records", records) - .put(Pagination.PAGINATION, pagination); - - context.renderJSON(new JSONObject() - .put(Keys.CODE, StatusCodes.SUCC) - .put(Keys.MSG, "") - .put(Common.DATA, data)); - } - - private int getPositiveIntParam(final RequestContext context, final String key, final int defaultValue) { - final String value = StringUtils.trim(context.param(key)); - if (StringUtils.isBlank(value)) { - return defaultValue; - } - - try { - final int ret = Integer.parseInt(value); - return ret > 0 ? ret : defaultValue; - } catch (final NumberFormatException e) { - return defaultValue; - } - } - - private void renderUserPointRecordsError(final RequestContext context, final String msg) { - context.renderJSON(new JSONObject() - .put(Keys.CODE, StatusCodes.ERR) - .put(Keys.MSG, msg) - .put(Common.DATA, new JSONObject())); - } - /** * List usernames. * diff --git a/src/main/java/org/b3log/symphony/util/OpenIdUtil.java b/src/main/java/org/b3log/symphony/util/OpenIdUtil.java index e54a4aa0f..815f46b9e 100644 --- a/src/main/java/org/b3log/symphony/util/OpenIdUtil.java +++ b/src/main/java/org/b3log/symphony/util/OpenIdUtil.java @@ -17,16 +17,26 @@ * along with this program. If not, see . */ package org.b3log.symphony.util; + +import org.json.JSONObject; + import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.util.Base64; import java.util.*; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + public class OpenIdUtil { private static final String SECRET = Symphonys.get("openid.secret"); + private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder(); + public static String generateNonce() { // 时间部分 SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); @@ -63,4 +73,51 @@ public static Date extractNonceTimestamp(String nonce) throws ParseException { sdf.setTimeZone(TimeZone.getTimeZone("UTC")); return sdf.parse(timestampPart); } + + public static String generateAccessToken(final String userId, final Collection scopes, + final String realm, final long expiresAt) throws Exception { + final long now = System.currentTimeMillis(); + final JSONObject payload = new JSONObject() + .put("userId", userId) + .put("scope", String.join(" ", scopes)) + .put("realm", realm) + .put("iat", now) + .put("exp", expiresAt) + .put("jti", UUID.randomUUID().toString().replace("-", "")); + final String encodedPayload = URL_ENCODER.encodeToString(payload.toString().getBytes(StandardCharsets.UTF_8)); + final String signature = URL_ENCODER.encodeToString(hmac(encodedPayload)); + + return encodedPayload + "." + signature; + } + + public static JSONObject parseAccessToken(final String token) { + try { + final String[] parts = token.split("\\."); + if (2 != parts.length) { + return null; + } + + final byte[] expectedSignature = hmac(parts[0]); + final byte[] actualSignature = URL_DECODER.decode(parts[1]); + if (!MessageDigest.isEqual(expectedSignature, actualSignature)) { + return null; + } + + final JSONObject payload = new JSONObject(new String(URL_DECODER.decode(parts[0]), StandardCharsets.UTF_8)); + if (payload.optLong("exp") < System.currentTimeMillis()) { + return null; + } + + return payload; + } catch (final Exception e) { + return null; + } + } + + private static byte[] hmac(final String payload) throws Exception { + final Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + + return mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + } } diff --git a/src/main/resources/skins/classic/mobile/verify/openid.ftl b/src/main/resources/skins/classic/mobile/verify/openid.ftl index 35b930541..bf870b95b 100644 --- a/src/main/resources/skins/classic/mobile/verify/openid.ftl +++ b/src/main/resources/skins/classic/mobile/verify/openid.ftl @@ -33,8 +33,8 @@
-
使用您的 ${visionLabel} 账户登录到 ${realmName}
-
请注意,${realmName} 不附属于 ${symphonyLabel}
+
登录到 ${realmName}
+
非 ${symphonyLabel} 站点
@@ -44,25 +44,43 @@ 这不是您?
+ - +
+ <#list fishpiScopes as scope> + + +
+
+ + +
-
通过 ${visionLabel} 账户登录到 ${realmName}:
+
授权内容
    -
  • 您的 ${visionLabel} 登录凭据不会被共享。
  • -
  • 将与 ${realmName} 共享唯一的数字标识符。
  • -
  • ${realmName} 可以获得您在 ${visionLabel} 上的头像昵称用户名
  • +
  • 个人信息:头像、昵称、用户名
  • +
  • 积分信息:余额、记录
  • +
  • 发帖信息:公开发帖
-
点击“登录”表示您同意共享此数据。
+
登录即授权所选项
diff --git a/src/main/resources/skins/classic/pc/verify/openid.ftl b/src/main/resources/skins/classic/pc/verify/openid.ftl index a4d3290fa..00194c01b 100644 --- a/src/main/resources/skins/classic/pc/verify/openid.ftl +++ b/src/main/resources/skins/classic/pc/verify/openid.ftl @@ -37,8 +37,8 @@
-
使用您的 ${visionLabel} 账户登录到 ${realmName}
-
请注意,${realmName} 不附属于 ${symphonyLabel}
+
登录到 ${realmName}
+
非 ${symphonyLabel} 站点
@@ -48,25 +48,43 @@ 这不是您?
+ - +
+ <#list fishpiScopes as scope> + + +
+
+ + +
-
通过 ${visionLabel} 账户登录到 ${realmName}:
+
授权内容
    -
  • 您的 ${visionLabel} 登录凭据不会被共享。
  • -
  • 将与 ${realmName} 共享唯一的数字标识符。
  • -
  • ${realmName} 可以获得您在 ${visionLabel} 上的头像昵称用户名
  • +
  • 个人信息:头像、昵称、用户名
  • +
  • 积分信息:余额、记录
  • +
  • 发帖信息:公开发帖
-
点击“登录”表示您同意共享此数据。
+
登录即授权所选项
From 81bdbc28c588cce74952c4afd3a2d3f06b0c2335 Mon Sep 17 00:00:00 2001 From: Yui Date: Mon, 22 Jun 2026 16:17:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20OAuth=20=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E6=97=A0=E6=84=9F=E7=BB=AD=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- API.md | 43 +++- sql/20260622_openid_refresh_token.sql | 20 ++ .../symphony/model/OpenIdRefreshToken.java | 48 ++++ .../symphony/processor/OpenIdProcessor.java | 64 ++++- .../OpenIdRefreshTokenRepository.java | 57 +++++ .../OpenIdRefreshTokenMgmtService.java | 231 ++++++++++++++++++ .../org/b3log/symphony/util/OpenIdUtil.java | 18 ++ src/main/resources/repository.json | 19 ++ src/main/resources/symphony.properties | 3 + 10 files changed, 493 insertions(+), 12 deletions(-) create mode 100644 sql/20260622_openid_refresh_token.sql create mode 100644 src/main/java/org/b3log/symphony/model/OpenIdRefreshToken.java create mode 100644 src/main/java/org/b3log/symphony/repository/OpenIdRefreshTokenRepository.java create mode 100644 src/main/java/org/b3log/symphony/service/OpenIdRefreshTokenMgmtService.java diff --git a/AGENTS.md b/AGENTS.md index 0b27de7c9..06902fc32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,7 @@ - `BeforeRequestHandler` 获取当前用户优先用 `UserQueryService#getCurrentUser(request)`(底层 `Sessions.currentUser(request)`);`Sessions.getUser()` 仅读取 ThreadLocal,未 `Sessions.setUser(...)` 前通常为 `null`。 - `LoginCheckMidware#handle`:未登录统一 401(特殊 URI `/gen` 返回空 SVG);支持 `Sessions` 与 `apiKey` 两种登录态来源。 - 新接口若需“页面登录态 + apiKey 调用”双兼容,路由层优先挂 `loginCheck::handle`,处理方法再读取 `context.attr(User.USER)`;避免只挂 `permission` 导致拿不到当前用户对象。 -- OAuth/OpenID 用户资源链路在 `OpenIdProcessor`:`/openid/login` 通过 `fishpi.scope` 请求 `profile.read/points.read/articles.read`,`/openid/verify` 成功后返回短期 Bearer `access_token`;`/openid/user/*` 资源接口只认该 token,不挂 `loginCheck`、不接收 `apiKey`、不支持 `userId` 代查。 +- OAuth/OpenID 用户资源链路在 `OpenIdProcessor`:`/openid/login` 通过 `fishpi.scope` 请求 `profile.read/points.read/articles.read`,`/openid/verify` 成功后返回 Bearer `access_token` 与可轮换 `refresh_token`;资源接口只认 `Authorization: Bearer`,续签走匿名 `POST /openid/token`,不接收 `apiKey`、不支持 `userId` 代查。 - 文章页实时链路走 `ArticleChannel`:新增评论仍是 `type=comment`,评论 reaction 增量走 `type=commentReaction`,帖子本体 reaction 增量走 `type=articleReaction`;联调这类 Java 推送改动时,前端强刷还不够,必须让用户重启或重新编译服务端。 - `AnonymousViewCheckMidware#handle`:匿名访问触发验证码(2 小时首次访问 + 每 5 次访问),并结合 `anonymous.viewSkips`、文章匿名开关、匿名访问次数 Cookie 限制。 - `Server` 启动逻辑:`DEVELOPMENT` 模式会关闭 `Firewall` 与 `AnonymousViewCheck`(验证码盾),联调时不要误判“线上无校验”。 diff --git a/API.md b/API.md index fb345aaea..db75f1b0d 100644 --- a/API.md +++ b/API.md @@ -489,7 +489,7 @@ curl --location --request POST 'https://fishpi.cn/report' \ `GET /openid/login` -第三方登录时可通过 `fishpi.scope` 请求用户资源权限。用户授权后,`POST /openid/verify` 成功响应会追加 `access_token`。 +第三方登录时可通过 `fishpi.scope` 请求用户资源权限。用户授权后,`POST /openid/verify` 成功响应会追加 `access_token` 和 `refresh_token`。 请求: @@ -510,10 +510,49 @@ curl --location --request GET 'https://fishpi.cn/openid/login?openid.ns=http%3A% | scope | 用户最终授权范围 | profile.read points.read | | token_type | 令牌类型 | Bearer | | access_token | OAuth 用户资源访问令牌 | xxx | -| expires_in | 有效期,单位秒 | 1800 | +| expires_in | 有效期,单位秒 | 604800 | +| refresh_token | OAuth 续签令牌 | xxx | +| refresh_expires_in | 续签令牌空闲有效期,单位秒 | 31104000 | > `profile.read` 为基础权限;站点请求的权限为必选,用户可额外勾选更多权限。 +### 续签 OAuth 访问令牌 + +`POST /openid/token` + +使用 `refresh_token` 续签 OAuth 访问令牌。不接受 `apiKey`。 + +请求: +| Key | 说明 | 示例 | +| ------------- | ---------------------------- | ------------- | +| grant_type | 固定为 `refresh_token` | refresh_token | +| refresh_token | OAuth 续签令牌 | xxx | + +请求示例: +```bash +curl --location --request POST 'https://fishpi.cn/openid/token' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "grant_type": "refresh_token", + "refresh_token": "xxx" +}' +``` + +响应: +| Key | 说明 | 示例 | +| ---------------------- | ---------------------------- | ------------------------ | +| code | 0 为成功,-1 为失败 | 0 | +| msg | 错误消息 | | +| data | 响应数据 | | +| - token_type | 令牌类型 | Bearer | +| - access_token | OAuth 用户资源访问令牌 | xxx | +| - expires_in | 访问令牌有效期,单位秒 | 604800 | +| - refresh_token | 新 OAuth 续签令牌 | xxx | +| - refresh_expires_in | 续签令牌空闲有效期,单位秒 | 31104000 | +| - scope | 用户最终授权范围 | profile.read points.read | + +> 每次续签都会返回新的 `refresh_token`,旧 `refresh_token` 会立即失效;续签令牌最长不超过首次授权后 720 天;请不要把 `refresh_token` 暴露到前端页面或日志中。 + ### 查询 OAuth 用户信息 `GET /openid/user/profile` diff --git a/sql/20260622_openid_refresh_token.sql b/sql/20260622_openid_refresh_token.sql new file mode 100644 index 000000000..221cf0de7 --- /dev/null +++ b/sql/20260622_openid_refresh_token.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS `symphony_openid_refresh_token` ( + `oId` VARCHAR(19) NOT NULL, + `tokenHash` VARCHAR(128) NOT NULL, + `userId` VARCHAR(19) NOT NULL, + `realm` VARCHAR(512) NOT NULL DEFAULT '', + `scope` VARCHAR(255) NOT NULL DEFAULT '', + `familyId` VARCHAR(64) NOT NULL, + `parentId` VARCHAR(19) NOT NULL DEFAULT '', + `state` INT NOT NULL DEFAULT 0, + `createdAt` BIGINT NOT NULL, + `updatedAt` BIGINT NOT NULL, + `lastUsedAt` BIGINT NOT NULL DEFAULT 0, + `idleExpiresAt` BIGINT NOT NULL, + `maxExpiresAt` BIGINT NOT NULL, + PRIMARY KEY (`oId`), + UNIQUE KEY `uk_openid_refresh_token_hash` (`tokenHash`), + KEY `idx_openid_refresh_token_family_state` (`familyId`, `state`), + KEY `idx_openid_refresh_token_user_realm` (`userId`, `realm`(191)), + KEY `idx_openid_refresh_token_expires` (`idleExpiresAt`, `maxExpiresAt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/src/main/java/org/b3log/symphony/model/OpenIdRefreshToken.java b/src/main/java/org/b3log/symphony/model/OpenIdRefreshToken.java new file mode 100644 index 000000000..e95dff23c --- /dev/null +++ b/src/main/java/org/b3log/symphony/model/OpenIdRefreshToken.java @@ -0,0 +1,48 @@ +/* + * Rhythm - A modern community (forum/BBS/SNS/blog) platform written in Java. + * Modified version from Symphony, Thanks Symphony :) + * Copyright (C) 2012-present, b3log.org + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.b3log.symphony.model; + +/** + * OpenID refresh token model constants. + */ +public final class OpenIdRefreshToken { + + private OpenIdRefreshToken() { + } + + public static final String OPENID_REFRESH_TOKEN = "openid_refresh_token"; + + public static final String TOKEN_HASH = "tokenHash"; + public static final String USER_ID = "userId"; + public static final String REALM = "realm"; + public static final String SCOPE = "scope"; + public static final String FAMILY_ID = "familyId"; + public static final String PARENT_ID = "parentId"; + public static final String STATE = "state"; + public static final String CREATED_AT = "createdAt"; + public static final String UPDATED_AT = "updatedAt"; + public static final String LAST_USED_AT = "lastUsedAt"; + public static final String IDLE_EXPIRES_AT = "idleExpiresAt"; + public static final String MAX_EXPIRES_AT = "maxExpiresAt"; + + public static final int STATE_ACTIVE = 0; + public static final int STATE_ROTATED = 1; + public static final int STATE_REVOKED = 2; + public static final int STATE_EXPIRED = 3; +} diff --git a/src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java b/src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java index db5e35774..2dfe0a0e1 100644 --- a/src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java +++ b/src/main/java/org/b3log/symphony/processor/OpenIdProcessor.java @@ -32,6 +32,7 @@ import org.b3log.latke.ioc.Inject; import org.b3log.latke.ioc.Singleton; import org.b3log.latke.model.Pagination; +import org.b3log.latke.service.ServiceException; import org.b3log.latke.util.Paginator; import org.b3log.symphony.model.Article; import org.b3log.symphony.model.Common; @@ -42,6 +43,7 @@ import org.b3log.symphony.service.ActivityMgmtService; import org.b3log.symphony.service.ArticleQueryService; import org.b3log.symphony.service.DataModelService; +import org.b3log.symphony.service.OpenIdRefreshTokenMgmtService; import org.b3log.symphony.service.PointtransferQueryService; import org.b3log.symphony.service.UserQueryService; import org.b3log.symphony.util.OpenIdUtil; @@ -85,7 +87,6 @@ public class OpenIdProcessor { private static final String SCOPE_POINTS_READ = "points.read"; private static final String SCOPE_ARTICLES_READ = "articles.read"; private static final long OPENID_TMP_EXPIRES = 5 * 60 * 1000L; - private static final long OPENID_ACCESS_TOKEN_EXPIRES = 30 * 60 * 1000L; private static final List SCOPE_DEFINITIONS = Arrays.asList( new ScopeDefinition(SCOPE_PROFILE_READ, "个人信息"), new ScopeDefinition(SCOPE_POINTS_READ, "积分信息"), @@ -111,6 +112,9 @@ public class OpenIdProcessor { @Inject private ArticleQueryService articleQueryService; + @Inject + private OpenIdRefreshTokenMgmtService openIdRefreshTokenMgmtService; + public static void register() { final BeanManager beanManager = BeanManager.getInstance(); final LoginCheckMidware loginCheck = beanManager.getReference(LoginCheckMidware.class); @@ -121,6 +125,7 @@ public static void register() { Dispatcher.get("/openid/login", openIdProcessor::showLoginForm, loginCheck::handle,csrfMidware::fill); Dispatcher.post("/openid/confirm", openIdProcessor::confirm, loginCheck::handle,csrfMidware::fill); Dispatcher.post("/openid/verify", openIdProcessor::verify, csrfMidware::fill); + Dispatcher.post("/openid/token", openIdProcessor::refreshToken); Dispatcher.get("/openid/user/profile", openIdProcessor::getProfile); Dispatcher.get("/openid/user/points", openIdProcessor::getPoints); Dispatcher.get("/openid/user/articles", openIdProcessor::getArticles); @@ -451,15 +456,16 @@ private void sendVerifyResult(final RequestContext context,Boolean result, final final StringBuilder builder = new StringBuilder("ns:http://specs.openid.net/auth/2.0\nis_valid:true\n"); if (null != authResult && !authResult.scopes.isEmpty()) { try { - final long expiresAt = System.currentTimeMillis() + OPENID_ACCESS_TOKEN_EXPIRES; - builder.append("scope:").append(String.join(" ", authResult.scopes)).append("\n"); - builder.append("token_type:Bearer\n"); - builder.append("access_token:") - .append(OpenIdUtil.generateAccessToken(authResult.userId, authResult.scopes, authResult.realm, expiresAt)) - .append("\n"); - builder.append("expires_in:").append(OPENID_ACCESS_TOKEN_EXPIRES / 1000).append("\n"); + final JSONObject tokenData = openIdRefreshTokenMgmtService.issueToken(authResult.userId, + authResult.scopes, authResult.realm); + builder.append("scope:").append(tokenData.optString("scope")).append("\n"); + builder.append("token_type:").append(tokenData.optString("token_type")).append("\n"); + builder.append("access_token:").append(tokenData.optString("access_token")).append("\n"); + builder.append("expires_in:").append(tokenData.optLong("expires_in")).append("\n"); + builder.append("refresh_token:").append(tokenData.optString("refresh_token")).append("\n"); + builder.append("refresh_expires_in:").append(tokenData.optLong("refresh_expires_in")).append("\n"); } catch (final Exception e) { - LOGGER.log(Level.ERROR, "Generates OpenID access token failed", e); + LOGGER.log(Level.ERROR, "Generates OpenID token failed", e); context.sendString("ns:http://specs.openid.net/auth/2.0\nis_valid:false\n"); return; } @@ -471,6 +477,46 @@ private void sendVerifyResult(final RequestContext context,Boolean result, final } + public void refreshToken(final RequestContext context) { + JSONObject requestJSONObject = null; + String grantType = StringUtils.trimToEmpty(context.param("grant_type")); + if (StringUtils.isBlank(grantType)) { + requestJSONObject = readRequestJSON(context); + grantType = StringUtils.trimToEmpty(requestJSONObject.optString("grant_type")); + } + if (!"refresh_token".equals(grantType)) { + renderError(context, "参数错误"); + return; + } + + String refreshToken = StringUtils.trimToEmpty(context.param("refresh_token")); + if (StringUtils.isBlank(refreshToken)) { + if (null == requestJSONObject) { + requestJSONObject = readRequestJSON(context); + } + refreshToken = StringUtils.trimToEmpty(requestJSONObject.optString("refresh_token")); + } + if (!OpenIdUtil.isRefreshTokenFormat(refreshToken)) { + renderAccessDenied(context); + return; + } + + try { + renderSucc(context, openIdRefreshTokenMgmtService.refresh(refreshToken)); + } catch (final ServiceException e) { + renderAccessDenied(context); + } + } + + private JSONObject readRequestJSON(final RequestContext context) { + try { + final JSONObject ret = context.requestJSON(); + return null == ret ? new JSONObject() : ret; + } catch (final Exception e) { + return new JSONObject(); + } + } + public void getProfile(final RequestContext context) { final JSONObject user = requireOpenIdUser(context, SCOPE_PROFILE_READ); if (null == user) { diff --git a/src/main/java/org/b3log/symphony/repository/OpenIdRefreshTokenRepository.java b/src/main/java/org/b3log/symphony/repository/OpenIdRefreshTokenRepository.java new file mode 100644 index 000000000..4984d2c78 --- /dev/null +++ b/src/main/java/org/b3log/symphony/repository/OpenIdRefreshTokenRepository.java @@ -0,0 +1,57 @@ +/* + * Rhythm - A modern community (forum/BBS/SNS/blog) platform written in Java. + * Modified version from Symphony, Thanks Symphony :) + * Copyright (C) 2012-present, b3log.org + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.b3log.symphony.repository; + +import org.b3log.latke.repository.AbstractRepository; +import org.b3log.latke.repository.CompositeFilterOperator; +import org.b3log.latke.repository.FilterOperator; +import org.b3log.latke.repository.PropertyFilter; +import org.b3log.latke.repository.Query; +import org.b3log.latke.repository.RepositoryException; +import org.b3log.latke.repository.annotation.Repository; +import org.b3log.symphony.model.OpenIdRefreshToken; +import org.json.JSONObject; + +import java.util.List; + +/** + * OpenID refresh token repository. + */ +@Repository +public class OpenIdRefreshTokenRepository extends AbstractRepository { + + public OpenIdRefreshTokenRepository() { + super(OpenIdRefreshToken.OPENID_REFRESH_TOKEN); + } + + public JSONObject getByTokenHash(final String tokenHash) throws RepositoryException { + final Query query = new Query().setFilter(new PropertyFilter(OpenIdRefreshToken.TOKEN_HASH, + FilterOperator.EQUAL, tokenHash)).setPageCount(1); + return getFirst(query); + } + + public List getActiveTokensByFamilyId(final String familyId) throws RepositoryException { + final Query query = new Query().setFilter(CompositeFilterOperator.and( + new PropertyFilter(OpenIdRefreshToken.FAMILY_ID, FilterOperator.EQUAL, familyId), + new PropertyFilter(OpenIdRefreshToken.STATE, FilterOperator.EQUAL, OpenIdRefreshToken.STATE_ACTIVE))) + .setPage(1, 1000) + .setPageCount(1); + return getList(query); + } +} diff --git a/src/main/java/org/b3log/symphony/service/OpenIdRefreshTokenMgmtService.java b/src/main/java/org/b3log/symphony/service/OpenIdRefreshTokenMgmtService.java new file mode 100644 index 000000000..103446932 --- /dev/null +++ b/src/main/java/org/b3log/symphony/service/OpenIdRefreshTokenMgmtService.java @@ -0,0 +1,231 @@ +/* + * Rhythm - A modern community (forum/BBS/SNS/blog) platform written in Java. + * Modified version from Symphony, Thanks Symphony :) + * Copyright (C) 2012-present, b3log.org + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package org.b3log.symphony.service; + +import org.apache.commons.lang.StringUtils; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.b3log.latke.Keys; +import org.b3log.latke.ioc.Inject; +import org.b3log.latke.repository.RepositoryException; +import org.b3log.latke.repository.Transaction; +import org.b3log.latke.service.ServiceException; +import org.b3log.latke.service.annotation.Service; +import org.b3log.latke.util.Ids; +import org.b3log.symphony.model.OpenIdRefreshToken; +import org.b3log.symphony.model.UserExt; +import org.b3log.symphony.repository.OpenIdRefreshTokenRepository; +import org.b3log.symphony.util.OpenIdUtil; +import org.b3log.symphony.util.Symphonys; +import org.json.JSONObject; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * OpenID refresh token management service. + */ +@Service +public class OpenIdRefreshTokenMgmtService { + + private static final Logger LOGGER = LogManager.getLogger(OpenIdRefreshTokenMgmtService.class); + + private static final String TOKEN_TYPE = "Bearer"; + private static final String ACCESS_TOKEN = "access_token"; + private static final String EXPIRES_IN = "expires_in"; + private static final String REFRESH_TOKEN = "refresh_token"; + private static final String REFRESH_EXPIRES_IN = "refresh_expires_in"; + private static final String SCOPE = "scope"; + private static final String TOKEN_TYPE_KEY = "token_type"; + + private static final long DEFAULT_ACCESS_TOKEN_EXPIRES_SECONDS = TimeUnit.DAYS.toSeconds(7); + private static final long DEFAULT_REFRESH_TOKEN_IDLE_EXPIRES_SECONDS = TimeUnit.DAYS.toSeconds(360); + private static final long DEFAULT_REFRESH_TOKEN_MAX_EXPIRES_SECONDS = TimeUnit.DAYS.toSeconds(720); + private static final long ACCESS_TOKEN_EXPIRES_SECONDS = getSeconds("openid.accessTokenExpires", + DEFAULT_ACCESS_TOKEN_EXPIRES_SECONDS); + private static final long REFRESH_TOKEN_IDLE_EXPIRES_SECONDS = getSeconds("openid.refreshTokenIdleExpires", + DEFAULT_REFRESH_TOKEN_IDLE_EXPIRES_SECONDS); + private static final long REFRESH_TOKEN_MAX_EXPIRES_SECONDS = getSeconds("openid.refreshTokenMaxExpires", + DEFAULT_REFRESH_TOKEN_MAX_EXPIRES_SECONDS); + private static final long ACCESS_TOKEN_EXPIRES_MILLIS = TimeUnit.SECONDS.toMillis(ACCESS_TOKEN_EXPIRES_SECONDS); + private static final long REFRESH_TOKEN_IDLE_EXPIRES_MILLIS = TimeUnit.SECONDS.toMillis( + REFRESH_TOKEN_IDLE_EXPIRES_SECONDS); + private static final long REFRESH_TOKEN_MAX_EXPIRES_MILLIS = TimeUnit.SECONDS.toMillis( + REFRESH_TOKEN_MAX_EXPIRES_SECONDS); + + @Inject + private OpenIdRefreshTokenRepository openIdRefreshTokenRepository; + + @Inject + private UserQueryService userQueryService; + + public JSONObject issueToken(final String userId, final Collection scopes, final String realm) + throws ServiceException { + final Transaction transaction = openIdRefreshTokenRepository.beginTransaction(); + try { + final JSONObject data = issueTokenInternal(userId, String.join(" ", scopes), realm, null, null); + transaction.commit(); + return data; + } catch (final Exception e) { + rollback(transaction); + LOGGER.log(Level.ERROR, "Issues OpenID token failed", e); + throw new ServiceException(e); + } + } + + public synchronized JSONObject refresh(final String refreshToken) throws ServiceException { + if (!OpenIdUtil.isRefreshTokenFormat(refreshToken)) { + throw new ServiceException("Unauthorized"); + } + + final Transaction transaction = openIdRefreshTokenRepository.beginTransaction(); + try { + final String tokenHash = OpenIdUtil.hashRefreshToken(refreshToken); + final JSONObject current = openIdRefreshTokenRepository.getByTokenHash(tokenHash); + if (null == current) { + throw new ServiceException("Unauthorized"); + } + + final long now = System.currentTimeMillis(); + if (OpenIdRefreshToken.STATE_ROTATED == current.optInt(OpenIdRefreshToken.STATE)) { + revokeActiveFamily(current.optString(OpenIdRefreshToken.FAMILY_ID), now); + transaction.commit(); + throw new ServiceException("Unauthorized"); + } + + if (OpenIdRefreshToken.STATE_ACTIVE != current.optInt(OpenIdRefreshToken.STATE)) { + throw new ServiceException("Unauthorized"); + } + + if (current.optLong(OpenIdRefreshToken.IDLE_EXPIRES_AT) <= now + || current.optLong(OpenIdRefreshToken.MAX_EXPIRES_AT) <= now) { + current.put(OpenIdRefreshToken.STATE, OpenIdRefreshToken.STATE_EXPIRED); + current.put(OpenIdRefreshToken.UPDATED_AT, now); + openIdRefreshTokenRepository.update(current.optString(Keys.OBJECT_ID), current); + transaction.commit(); + throw new ServiceException("Unauthorized"); + } + + final JSONObject user = userQueryService.getUser(current.optString(OpenIdRefreshToken.USER_ID)); + if (null == user || UserExt.USER_STATUS_C_VALID != user.optInt(UserExt.USER_STATUS)) { + current.put(OpenIdRefreshToken.STATE, OpenIdRefreshToken.STATE_REVOKED); + current.put(OpenIdRefreshToken.UPDATED_AT, now); + openIdRefreshTokenRepository.update(current.optString(Keys.OBJECT_ID), current); + transaction.commit(); + throw new ServiceException("Unauthorized"); + } + + current.put(OpenIdRefreshToken.STATE, OpenIdRefreshToken.STATE_ROTATED); + current.put(OpenIdRefreshToken.LAST_USED_AT, now); + current.put(OpenIdRefreshToken.UPDATED_AT, now); + openIdRefreshTokenRepository.update(current.optString(Keys.OBJECT_ID), current); + + final JSONObject data = issueTokenInternal(current.optString(OpenIdRefreshToken.USER_ID), + current.optString(OpenIdRefreshToken.SCOPE), current.optString(OpenIdRefreshToken.REALM), + current.optString(OpenIdRefreshToken.FAMILY_ID), current); + transaction.commit(); + return data; + } catch (final ServiceException e) { + rollback(transaction); + throw e; + } catch (final Exception e) { + rollback(transaction); + LOGGER.log(Level.ERROR, "Refreshes OpenID token failed", e); + throw new ServiceException(e); + } + } + + private JSONObject issueTokenInternal(final String userId, final String scope, final String realm, + final String familyId, final JSONObject parent) + throws Exception { + final long now = System.currentTimeMillis(); + final String normalizedScope = StringUtils.trimToEmpty(scope); + final String normalizedRealm = StringUtils.left(StringUtils.trimToEmpty(realm), 512); + final long maxExpiresAt = null == parent + ? now + REFRESH_TOKEN_MAX_EXPIRES_MILLIS + : parent.optLong(OpenIdRefreshToken.MAX_EXPIRES_AT); + final long idleExpiresAt = Math.min(now + REFRESH_TOKEN_IDLE_EXPIRES_MILLIS, maxExpiresAt); + if (idleExpiresAt <= now) { + throw new ServiceException("Unauthorized"); + } + + final String token = OpenIdUtil.generateRefreshToken(); + final String tokenId = Ids.genTimeMillisId(); + final JSONObject refreshToken = new JSONObject() + .put(Keys.OBJECT_ID, tokenId) + .put(OpenIdRefreshToken.TOKEN_HASH, OpenIdUtil.hashRefreshToken(token)) + .put(OpenIdRefreshToken.USER_ID, userId) + .put(OpenIdRefreshToken.REALM, normalizedRealm) + .put(OpenIdRefreshToken.SCOPE, normalizedScope) + .put(OpenIdRefreshToken.FAMILY_ID, StringUtils.isBlank(familyId) + ? UUID.randomUUID().toString().replace("-", "") + : familyId) + .put(OpenIdRefreshToken.PARENT_ID, null == parent ? "" : parent.optString(Keys.OBJECT_ID)) + .put(OpenIdRefreshToken.STATE, OpenIdRefreshToken.STATE_ACTIVE) + .put(OpenIdRefreshToken.CREATED_AT, now) + .put(OpenIdRefreshToken.UPDATED_AT, now) + .put(OpenIdRefreshToken.LAST_USED_AT, 0L) + .put(OpenIdRefreshToken.IDLE_EXPIRES_AT, idleExpiresAt) + .put(OpenIdRefreshToken.MAX_EXPIRES_AT, maxExpiresAt); + openIdRefreshTokenRepository.add(refreshToken); + + final long accessExpiresAt = now + ACCESS_TOKEN_EXPIRES_MILLIS; + return new JSONObject() + .put(SCOPE, normalizedScope) + .put(TOKEN_TYPE_KEY, TOKEN_TYPE) + .put(ACCESS_TOKEN, OpenIdUtil.generateAccessToken(userId, + StringUtils.isBlank(normalizedScope) ? java.util.Collections.emptyList() : java.util.Arrays.asList(normalizedScope.split("\\s+")), + normalizedRealm, accessExpiresAt)) + .put(EXPIRES_IN, ACCESS_TOKEN_EXPIRES_SECONDS) + .put(REFRESH_TOKEN, token) + .put(REFRESH_EXPIRES_IN, Math.max(0L, TimeUnit.MILLISECONDS.toSeconds(idleExpiresAt - now))); + } + + private void revokeActiveFamily(final String familyId, final long now) throws RepositoryException { + final List activeTokens = openIdRefreshTokenRepository.getActiveTokensByFamilyId(familyId); + for (final JSONObject activeToken : activeTokens) { + activeToken.put(OpenIdRefreshToken.STATE, OpenIdRefreshToken.STATE_REVOKED); + activeToken.put(OpenIdRefreshToken.UPDATED_AT, now); + openIdRefreshTokenRepository.update(activeToken.optString(Keys.OBJECT_ID), activeToken); + } + } + + private void rollback(final Transaction transaction) { + if (transaction.isActive()) { + transaction.rollback(); + } + } + + private static long getSeconds(final String key, final long defaultValue) { + final String value = StringUtils.trimToEmpty(Symphonys.get(key)); + if (StringUtils.isBlank(value)) { + return defaultValue; + } + + try { + final long ret = Long.parseLong(value); + return ret > 0 ? ret : defaultValue; + } catch (final NumberFormatException e) { + return defaultValue; + } + } +} diff --git a/src/main/java/org/b3log/symphony/util/OpenIdUtil.java b/src/main/java/org/b3log/symphony/util/OpenIdUtil.java index 815f46b9e..06e24ba47 100644 --- a/src/main/java/org/b3log/symphony/util/OpenIdUtil.java +++ b/src/main/java/org/b3log/symphony/util/OpenIdUtil.java @@ -22,10 +22,12 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.security.SecureRandom; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Base64; import java.util.*; +import java.util.regex.Pattern; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; @@ -36,6 +38,8 @@ public class OpenIdUtil { private static final String SECRET = Symphonys.get("openid.secret"); private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder(); + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final Pattern REFRESH_TOKEN_PATTERN = Pattern.compile("[A-Za-z0-9_-]{43,256}"); public static String generateNonce() { // 时间部分 @@ -114,6 +118,20 @@ public static JSONObject parseAccessToken(final String token) { } } + public static String generateRefreshToken() { + final byte[] random = new byte[32]; + SECURE_RANDOM.nextBytes(random); + return URL_ENCODER.encodeToString(random); + } + + public static boolean isRefreshTokenFormat(final String token) { + return null != token && REFRESH_TOKEN_PATTERN.matcher(token).matches(); + } + + public static String hashRefreshToken(final String token) throws Exception { + return URL_ENCODER.encodeToString(hmac("refresh:" + token)); + } + private static byte[] hmac(final String payload) throws Exception { final Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); diff --git a/src/main/resources/repository.json b/src/main/resources/repository.json index 9b3018e23..7f0b7bbfd 100644 --- a/src/main/resources/repository.json +++ b/src/main/resources/repository.json @@ -9,6 +9,25 @@ ], "since": "0.2.0", "repositories": [ + { + "name": "openid_refresh_token", + "description": "OpenID refresh token sessions", + "keys": [ + { "name": "oId", "type": "String", "length": 19, "description": "Primary key" }, + { "name": "tokenHash", "type": "String", "length": 128, "description": "Refresh token hash" }, + { "name": "userId", "type": "String", "length": 19, "description": "User ID" }, + { "name": "realm", "type": "String", "length": 512, "description": "OpenID realm" }, + { "name": "scope", "type": "String", "length": 255, "description": "Authorized scopes" }, + { "name": "familyId", "type": "String", "length": 64, "description": "Token family ID" }, + { "name": "parentId", "type": "String", "length": 19, "description": "Parent token row ID" }, + { "name": "state", "type": "int", "description": "0 active, 1 rotated, 2 revoked, 3 expired" }, + { "name": "createdAt", "type": "long", "description": "Created time" }, + { "name": "updatedAt", "type": "long", "description": "Updated time" }, + { "name": "lastUsedAt", "type": "long", "description": "Last used time" }, + { "name": "idleExpiresAt", "type": "long", "description": "Idle expiration time" }, + { "name": "maxExpiresAt", "type": "long", "description": "Max expiration time" } + ] + }, { "name": "membership_level", "description": "会员等级定义", diff --git a/src/main/resources/symphony.properties b/src/main/resources/symphony.properties index ddd46467e..5ab5ab154 100644 --- a/src/main/resources/symphony.properties +++ b/src/main/resources/symphony.properties @@ -390,6 +390,9 @@ jiyan.domain=http://gcaptcha4.geetest.com # openid openid.assocHandle=1234567890 openid.secret=4rfdsh546gfd%$^t435 +openid.accessTokenExpires=604800 +openid.refreshTokenIdleExpires=31104000 +openid.refreshTokenMaxExpires=62208000 # geoip geoip.config.mmdb=/root/GeoLite2-City.mmdb