From f0ffe2e419622196670e03710c70d322c7bc0895 Mon Sep 17 00:00:00 2001 From: Steven Date: Sat, 23 Sep 2023 08:54:27 +0800 Subject: [PATCH] chore: update workspace store --- api/v1/auth.go | 25 ++++++++ api/v1/jwt.go | 63 +++++++++---------- api/v2/acl.go | 22 +++---- frontend/web/src/App.tsx | 8 +-- .../components/setting/WorkspaceSection.tsx | 4 +- frontend/web/src/pages/SignIn.tsx | 4 +- frontend/web/src/services/workspaceService.ts | 2 +- frontend/web/src/stores/index.ts | 4 +- 8 files changed, 76 insertions(+), 56 deletions(-) diff --git a/api/v1/auth.go b/api/v1/auth.go index 9aca390..46bea57 100644 --- a/api/v1/auth.go +++ b/api/v1/auth.go @@ -122,7 +122,32 @@ func (s *APIV1Service) registerAuthRoutes(g *echo.Group, secret string) { }) g.POST("/auth/logout", func(c echo.Context) error { + ctx := c.Request().Context() RemoveTokensAndCookies(c) + accessToken := findAccessToken(c) + userID, _ := getUserIDFromAccessToken(accessToken, secret) + userAccessTokens, err := s.Store.GetUserAccessTokens(ctx, userID) + // Auto remove the current access token from the user access tokens. + if err == nil && len(userAccessTokens) != 0 { + accessTokens := []*storepb.AccessTokensUserSetting_AccessToken{} + for _, userAccessToken := range userAccessTokens { + if accessToken != userAccessToken.AccessToken { + accessTokens = append(accessTokens, userAccessToken) + } + } + + if _, err := s.Store.UpsertUserSetting(ctx, &storepb.UserSetting{ + UserId: userID, + Key: storepb.UserSettingKey_USER_SETTING_ACCESS_TOKENS, + Value: &storepb.UserSetting_AccessTokens{ + AccessTokens: &storepb.AccessTokensUserSetting{ + AccessTokens: accessTokens, + }, + }, + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to upsert user setting, err: %s", err)).SetInternal(err) + } + } c.Response().WriteHeader(http.StatusOK) return nil }) diff --git a/api/v1/jwt.go b/api/v1/jwt.go index 4fee265..5d5cbea 100644 --- a/api/v1/jwt.go +++ b/api/v1/jwt.go @@ -48,15 +48,6 @@ func findAccessToken(c echo.Context) string { return accessToken } -func audienceContains(audience jwt.ClaimStrings, token string) bool { - for _, v := range audience { - if v == token { - return true - } - } - return false -} - // JWTMiddleware validates the access token. func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.HandlerFunc { return func(c echo.Context) error { @@ -69,8 +60,8 @@ func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.H return next(c) } - token := findAccessToken(c) - if token == "" { + accessToken := findAccessToken(c) + if accessToken == "" { // When the request is not authenticated, we allow the user to access the shortcut endpoints for those public shortcuts. if util.HasPrefixes(path, "/s/") && method == http.MethodGet { return next(c) @@ -78,36 +69,16 @@ func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.H return echo.NewHTTPError(http.StatusUnauthorized, "Missing access token") } - claims := &auth.ClaimsMessage{} - _, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { - if t.Method.Alg() != jwt.SigningMethodHS256.Name { - return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256) - } - if kid, ok := t.Header["kid"].(string); ok { - if kid == "v1" { - return []byte(secret), nil - } - } - return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"]) - }) + userID, err := getUserIDFromAccessToken(accessToken, secret) if err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, errors.Wrap(err, "Invalid or expired access token")) - } - if !audienceContains(claims.Audience, auth.AccessTokenAudienceName) { - return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Invalid access token, audience mismatch, got %q, expected %q.", claims.Audience, auth.AccessTokenAudienceName)) - } - - // We either have a valid access token or we will attempt to generate new access token. - userID, err := util.ConvertStringToInt32(claims.Subject) - if err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "Malformed ID in the token.").WithInternal(err) + return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired access token") } accessTokens, err := s.Store.GetUserAccessTokens(ctx, userID) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user access tokens.").WithInternal(err) } - if !validateAccessToken(token, accessTokens) { + if !validateAccessToken(accessToken, accessTokens) { return echo.NewHTTPError(http.StatusUnauthorized, "Invalid access token.") } @@ -128,6 +99,30 @@ func JWTMiddleware(s *APIV1Service, next echo.HandlerFunc, secret string) echo.H } } +func getUserIDFromAccessToken(accessToken, secret string) (int32, error) { + claims := &auth.ClaimsMessage{} + _, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) { + if t.Method.Alg() != jwt.SigningMethodHS256.Name { + return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256) + } + if kid, ok := t.Header["kid"].(string); ok { + if kid == "v1" { + return []byte(secret), nil + } + } + return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"]) + }) + if err != nil { + return 0, errors.Wrap(err, "Invalid or expired access token") + } + // We either have a valid access token or we will attempt to generate new access token. + userID, err := util.ConvertStringToInt32(claims.Subject) + if err != nil { + return 0, errors.Wrap(err, "Malformed ID in the token") + } + return userID, nil +} + func validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool { for _, userAccessToken := range userAccessTokens { if accessTokenString == userAccessToken.AccessToken { diff --git a/api/v2/acl.go b/api/v2/acl.go index 9c29474..36306b2 100644 --- a/api/v2/acl.go +++ b/api/v2/acl.go @@ -72,25 +72,17 @@ func (in *GRPCAuthInterceptor) AuthenticationInterceptor(ctx context.Context, re return nil, status.Errorf(codes.PermissionDenied, "user ID %q is not admin", userID) } - userAccessTokens, err := in.Store.GetUserAccessTokens(ctx, userID) - if err != nil { - return nil, errors.Wrap(err, "failed to get user access tokens") - } - if !validateAccessToken(accessToken, userAccessTokens) { - return nil, status.Errorf(codes.Unauthenticated, "invalid access token") - } - // Stores userID into context. childCtx := context.WithValue(ctx, userIDContextKey, userID) return handler(childCtx, request) } -func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessTokenStr string) (int32, error) { - if accessTokenStr == "" { +func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessToken string) (int32, error) { + if accessToken == "" { return 0, status.Errorf(codes.Unauthenticated, "access token not found") } claims := &auth.ClaimsMessage{} - _, err := jwt.ParseWithClaims(accessTokenStr, claims, func(t *jwt.Token) (any, error) { + _, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) { if t.Method.Alg() != jwt.SigningMethodHS256.Name { return nil, status.Errorf(codes.Unauthenticated, "unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256) } @@ -129,6 +121,14 @@ func (in *GRPCAuthInterceptor) authenticate(ctx context.Context, accessTokenStr return 0, status.Errorf(codes.Unauthenticated, "user ID %q has been deactivated by administrators", userID) } + accessTokens, err := in.Store.GetUserAccessTokens(ctx, user.ID) + if err != nil { + return 0, errors.Wrapf(err, "failed to get user access tokens") + } + if !validateAccessToken(accessToken, accessTokens) { + return 0, status.Errorf(codes.Unauthenticated, "invalid access token") + } + return userID, nil } diff --git a/frontend/web/src/App.tsx b/frontend/web/src/App.tsx index b4af62d..c35ff00 100644 --- a/frontend/web/src/App.tsx +++ b/frontend/web/src/App.tsx @@ -8,7 +8,7 @@ import useUserStore from "./stores/v1/user"; import { WorkspaceSetting } from "./types/proto/api/v2/workspace_service"; function App() { - const { mode } = useColorScheme(); + const { mode: colorScheme } = useColorScheme(); const userStore = useUserStore(); const [workspaceSetting, setWorkspaceSetting] = useState(WorkspaceSetting.fromPartial({})); const [loading, setLoading] = useState(true); @@ -51,9 +51,9 @@ function App() { useEffect(() => { const root = document.documentElement; - if (mode === "light") { + if (colorScheme === "light") { root.classList.remove("dark"); - } else if (mode === "dark") { + } else if (colorScheme === "dark") { root.classList.add("dark"); } else { const darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); @@ -80,7 +80,7 @@ function App() { darkMediaQuery.removeEventListener("change", handleColorSchemeChange); }; } - }, [mode]); + }, [colorScheme]); return !loading ? ( <> diff --git a/frontend/web/src/components/setting/WorkspaceSection.tsx b/frontend/web/src/components/setting/WorkspaceSection.tsx index 34c5c90..1d845cd 100644 --- a/frontend/web/src/components/setting/WorkspaceSection.tsx +++ b/frontend/web/src/components/setting/WorkspaceSection.tsx @@ -9,7 +9,7 @@ import { WorkspaceSetting } from "@/types/proto/api/v2/workspace_service"; const WorkspaceSection: React.FC = () => { const [workspaceSetting, setWorkspaceSetting] = useState(WorkspaceSetting.fromPartial({})); const originalWorkspaceSetting = useRef(WorkspaceSetting.fromPartial({})); - const allowToSave = !isEqual(originalWorkspaceSetting.current, workspaceSetting); + const allowSave = !isEqual(originalWorkspaceSetting.current, workspaceSetting); useEffect(() => { workspaceServiceClient.getWorkspaceSetting({}).then(({ setting }) => { @@ -86,7 +86,7 @@ const WorkspaceSection: React.FC = () => {

Once enabled, other users can signup.

-
diff --git a/frontend/web/src/pages/SignIn.tsx b/frontend/web/src/pages/SignIn.tsx index bdb55f2..6321c46 100644 --- a/frontend/web/src/pages/SignIn.tsx +++ b/frontend/web/src/pages/SignIn.tsx @@ -3,9 +3,9 @@ import React, { FormEvent, useEffect, useState } from "react"; import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; +import { workspaceService } from "@/services"; import * as api from "../helpers/api"; import useLoading from "../hooks/useLoading"; -import { useAppSelector } from "../stores"; import useUserStore from "../stores/v1/user"; const SignIn: React.FC = () => { @@ -14,7 +14,7 @@ const SignIn: React.FC = () => { const userStore = useUserStore(); const { workspaceProfile: { enableSignup, mode }, - } = useAppSelector((state) => state.global); + } = workspaceService.getState(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const actionBtnLoadingState = useLoading(false); diff --git a/frontend/web/src/services/workspaceService.ts b/frontend/web/src/services/workspaceService.ts index dcad7a9..c7d2fda 100644 --- a/frontend/web/src/services/workspaceService.ts +++ b/frontend/web/src/services/workspaceService.ts @@ -5,7 +5,7 @@ import { setWorkspaceState } from "../stores/modules/workspace"; const workspaceService = { getState: () => { - return store.getState().global; + return store.getState().workspace; }, initialState: async () => { diff --git a/frontend/web/src/stores/index.ts b/frontend/web/src/stores/index.ts index d956e7d..8c893cc 100644 --- a/frontend/web/src/stores/index.ts +++ b/frontend/web/src/stores/index.ts @@ -1,11 +1,11 @@ import { configureStore } from "@reduxjs/toolkit"; import { TypedUseSelectorHook, useSelector } from "react-redux"; import shortcutReducer from "./modules/shortcut"; -import globalReducer from "./modules/workspace"; +import workspaceReducer from "./modules/workspace"; const store = configureStore({ reducer: { - global: globalReducer, + workspace: workspaceReducer, shortcut: shortcutReducer, }, });