mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
feat: implement sign in with SSO (#1119)
* feat: implement sign in with SSO * chore: update * chore: update * chore: update
This commit is contained in:
@ -5,6 +5,12 @@ type SignIn struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type SSOSignIn struct {
|
||||
IdentityProviderID int `json:"identityProviderId"`
|
||||
Code string `json:"code"`
|
||||
RedirectURI string `json:"redirectUri"`
|
||||
}
|
||||
|
||||
type SignUp struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
|
@ -1,6 +1,8 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
@ -35,3 +37,24 @@ func Min(x, y int) int {
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
var letters = []rune("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
// RandomString returns a random string with length n.
|
||||
func RandomString(n int) (string, error) {
|
||||
var sb strings.Builder
|
||||
sb.Grow(n)
|
||||
for i := 0; i < n; i++ {
|
||||
// The reason for using crypto/rand instead of math/rand is that
|
||||
// the former relies on hardware to generate random numbers and
|
||||
// thus has a stronger source of random numbers.
|
||||
randNum, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := sb.WriteRune(letters[randNum.Uint64()]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
@ -81,7 +81,7 @@ func aclMiddleware(s *Server, next echo.HandlerFunc) echo.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
if common.HasPrefixes(path, "/api/ping", "/api/status", "/api/user/:id", "/api/memo") && c.Request().Method == http.MethodGet {
|
||||
if common.HasPrefixes(path, "/api/ping", "/api/status", "/api/idp", "/api/user/:id", "/api/memo") && c.Request().Method == http.MethodGet {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
|
@ -4,11 +4,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/usememos/memos/api"
|
||||
"github.com/usememos/memos/common"
|
||||
"github.com/usememos/memos/plugin/idp"
|
||||
"github.com/usememos/memos/plugin/idp/oauth2"
|
||||
metric "github.com/usememos/memos/plugin/metrics"
|
||||
"github.com/usememos/memos/store"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@ -50,6 +54,90 @@ func (s *Server) registerAuthRoutes(g *echo.Group) {
|
||||
return c.JSON(http.StatusOK, composeResponse(user))
|
||||
})
|
||||
|
||||
g.POST("/auth/signin/sso", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signin := &api.SSOSignIn{}
|
||||
if err := json.NewDecoder(c.Request().Body).Decode(signin); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted signin request").SetInternal(err)
|
||||
}
|
||||
|
||||
identityProviderMessage, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProviderMessage{
|
||||
ID: &signin.IdentityProviderID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider").SetInternal(err)
|
||||
}
|
||||
|
||||
var userInfo *idp.IdentityProviderUserInfo
|
||||
if identityProviderMessage.Type == store.IdentityProviderOAuth2 {
|
||||
oauth2IdentityProvider, err := oauth2.NewIdentityProvider(identityProviderMessage.Config.OAuth2Config)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create identity provider instance").SetInternal(err)
|
||||
}
|
||||
token, err := oauth2IdentityProvider.ExchangeToken(ctx, signin.RedirectURI, signin.Code)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to exchange token").SetInternal(err)
|
||||
}
|
||||
userInfo, err = oauth2IdentityProvider.UserInfo(token)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get user info").SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
identifierFilter := identityProviderMessage.IdentifierFilter
|
||||
if identifierFilter != "" {
|
||||
identifierFilterRegex, err := regexp.Compile(identifierFilter)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to compile identifier filter").SetInternal(err)
|
||||
}
|
||||
if !identifierFilterRegex.MatchString(userInfo.Identifier) {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Access denied, identifier does not match the filter.").SetInternal(err)
|
||||
}
|
||||
}
|
||||
|
||||
user, err := s.Store.FindUser(ctx, &api.UserFind{
|
||||
Username: &userInfo.Identifier,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to find user by username %s", userInfo.Identifier)).SetInternal(err)
|
||||
}
|
||||
if user == nil {
|
||||
userCreate := &api.UserCreate{
|
||||
Username: userInfo.Identifier,
|
||||
// The new signup user should be normal user by default.
|
||||
Role: api.NormalUser,
|
||||
Nickname: userInfo.DisplayName,
|
||||
Email: userInfo.Email,
|
||||
Password: userInfo.Email,
|
||||
OpenID: common.GenUUID(),
|
||||
}
|
||||
password, err := common.RandomString(20)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate random password").SetInternal(err)
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate password hash").SetInternal(err)
|
||||
}
|
||||
userCreate.PasswordHash = string(passwordHash)
|
||||
user, err = s.Store.CreateUser(ctx, userCreate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user").SetInternal(err)
|
||||
}
|
||||
}
|
||||
if user.RowStatus == api.Archived {
|
||||
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("User has been archived with username %s", userInfo.Identifier))
|
||||
}
|
||||
|
||||
if err = setUserSession(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to set signin session").SetInternal(err)
|
||||
}
|
||||
if err := s.createUserAuthSignInActivity(c, user); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, composeResponse(user))
|
||||
})
|
||||
|
||||
g.POST("/auth/signup", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
signup := &api.SignUp{}
|
||||
|
@ -91,30 +91,33 @@ func (s *Server) registerIdentityProviderRoutes(g *echo.Group) {
|
||||
|
||||
g.GET("/idp", func(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||
}
|
||||
|
||||
user, err := s.Store.FindUser(ctx, &api.UserFind{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
// We should only show identity provider list to host user.
|
||||
if user == nil || user.Role != api.Host {
|
||||
return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
|
||||
identityProviderMessageList, err := s.Store.ListIdentityProviders(ctx, &store.FindIdentityProviderMessage{})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find identity provider list").SetInternal(err)
|
||||
}
|
||||
|
||||
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||
isHostUser := false
|
||||
if ok {
|
||||
user, err := s.Store.FindUser(ctx, &api.UserFind{
|
||||
ID: &userID,
|
||||
})
|
||||
if err != nil && common.ErrorCode(err) != common.NotFound {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find user").SetInternal(err)
|
||||
}
|
||||
if user != nil && user.Role == api.Host {
|
||||
isHostUser = true
|
||||
}
|
||||
}
|
||||
|
||||
identityProviderList := []*api.IdentityProvider{}
|
||||
for _, identityProviderMessage := range identityProviderMessageList {
|
||||
identityProviderList = append(identityProviderList, convertIdentityProviderFromStore(identityProviderMessage))
|
||||
identityProvider := convertIdentityProviderFromStore(identityProviderMessage)
|
||||
// data desensitize
|
||||
if !isHostUser {
|
||||
identityProvider.Config.OAuth2Config.ClientSecret = ""
|
||||
}
|
||||
identityProviderList = append(identityProviderList, identityProvider)
|
||||
}
|
||||
return c.JSON(http.StatusOK, composeResponse(identityProviderList))
|
||||
})
|
||||
|
@ -98,11 +98,9 @@ func (s *Store) CreateIdentityProvider(ctx context.Context, create *IdentityProv
|
||||
); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
|
||||
identityProviderMessage := create
|
||||
s.idpCache.Store(identityProviderMessage.ID, identityProviderMessage)
|
||||
return identityProviderMessage, nil
|
||||
@ -208,7 +206,9 @@ func (s *Store) UpdateIdentityProvider(ctx context.Context, update *UpdateIdenti
|
||||
} else {
|
||||
return nil, fmt.Errorf("unsupported idp type %s", string(identityProviderMessage.Type))
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, FormatError(err)
|
||||
}
|
||||
s.idpCache.Store(identityProviderMessage.ID, identityProviderMessage)
|
||||
return &identityProviderMessage, nil
|
||||
}
|
||||
@ -234,6 +234,9 @@ func (s *Store) DeleteIdentityProvider(ctx context.Context, delete *DeleteIdenti
|
||||
if rows == 0 {
|
||||
return &common.Error{Code: common.NotFound, Err: fmt.Errorf("idp not found")}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.idpCache.Delete(delete.ID)
|
||||
return nil
|
||||
}
|
||||
|
@ -8,7 +8,7 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.10.5",
|
||||
"@emotion/styled": "^11.10.5",
|
||||
"@mui/joy": "^5.0.0-alpha.63",
|
||||
"@mui/joy": "^5.0.0-alpha.67",
|
||||
"@reduxjs/toolkit": "^1.8.1",
|
||||
"axios": "^0.27.2",
|
||||
"copy-to-clipboard": "^3.3.2",
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Divider, Input, List, Radio, RadioGroup, Typography } from "@mui/joy";
|
||||
import { Alert, Button, Divider, Input, Radio, RadioGroup, Typography } from "@mui/joy";
|
||||
import * as api from "../helpers/api";
|
||||
import { UNKNOWN_ID } from "../helpers/consts";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import Icon from "./Icon";
|
||||
import toastHelper from "./Toast";
|
||||
@ -10,6 +12,72 @@ interface Props extends DialogProps {
|
||||
confirmCallback?: () => void;
|
||||
}
|
||||
|
||||
const templateList: IdentityProvider[] = [
|
||||
{
|
||||
id: UNKNOWN_ID,
|
||||
name: "GitHub",
|
||||
type: "OAUTH2",
|
||||
identifierFilter: "",
|
||||
config: {
|
||||
oauth2Config: {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "https://github.com/login/oauth/authorize",
|
||||
tokenUrl: "https://github.com/login/oauth/access_token",
|
||||
userInfoUrl: "https://api.github.com/user",
|
||||
scopes: ["user"],
|
||||
fieldMapping: {
|
||||
identifier: "login",
|
||||
displayName: "name",
|
||||
email: "email",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: UNKNOWN_ID,
|
||||
name: "Google",
|
||||
type: "OAUTH2",
|
||||
identifierFilter: "",
|
||||
config: {
|
||||
oauth2Config: {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
userInfoUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
scopes: ["https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"],
|
||||
fieldMapping: {
|
||||
identifier: "email",
|
||||
displayName: "name",
|
||||
email: "email",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: UNKNOWN_ID,
|
||||
name: "Custom",
|
||||
type: "OAUTH2",
|
||||
identifierFilter: "",
|
||||
config: {
|
||||
oauth2Config: {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
authUrl: "",
|
||||
tokenUrl: "",
|
||||
userInfoUrl: "",
|
||||
scopes: [],
|
||||
fieldMapping: {
|
||||
identifier: "",
|
||||
displayName: "",
|
||||
email: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
||||
const { confirmCallback, destroy, identityProvider } = props;
|
||||
const [basicInfo, setBasicInfo] = useState({
|
||||
@ -31,6 +99,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
||||
},
|
||||
});
|
||||
const [oauth2Scopes, setOAuth2Scopes] = useState<string>("");
|
||||
const [seletedTemplate, setSelectedTemplate] = useState<string>("GitHub");
|
||||
const isCreating = identityProvider === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
@ -47,6 +116,25 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreating) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = templateList.find((t) => t.name === seletedTemplate);
|
||||
if (template) {
|
||||
setBasicInfo({
|
||||
name: template.name,
|
||||
identifierFilter: template.identifierFilter,
|
||||
});
|
||||
setType(template.type);
|
||||
if (template.type === "OAUTH2") {
|
||||
setOAuth2Config(template.config.oauth2Config);
|
||||
setOAuth2Scopes(template.config.oauth2Config.scopes.join(" "));
|
||||
}
|
||||
}
|
||||
}, [seletedTemplate]);
|
||||
|
||||
const handleCloseBtnClick = () => {
|
||||
destroy();
|
||||
};
|
||||
@ -84,6 +172,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
toastHelper.info(`SSO ${basicInfo.name} created`);
|
||||
} else {
|
||||
await api.patchIdentityProvider({
|
||||
id: identityProvider?.id,
|
||||
@ -96,6 +185,7 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
toastHelper.info(`SSO ${basicInfo.name} updated`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
@ -124,14 +214,34 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
{isCreating && (
|
||||
<>
|
||||
<Typography className="!mb-1" level="body2">
|
||||
Type
|
||||
</Typography>
|
||||
<RadioGroup className="mb-2" value={type}>
|
||||
<List>
|
||||
<div className="mt-2 w-full flex flex-row space-x-4">
|
||||
<Radio value="OAUTH2" label="OAuth 2.0" />
|
||||
</List>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Typography className="mb-2" level="body2">
|
||||
Template
|
||||
</Typography>
|
||||
<RadioGroup className="mb-2" value={seletedTemplate}>
|
||||
<div className="mt-2 w-full flex flex-row space-x-4">
|
||||
{templateList.map((template) => (
|
||||
<Radio
|
||||
key={template.name}
|
||||
value={template.name}
|
||||
label={template.name}
|
||||
onChange={(e) => setSelectedTemplate(e.target.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Divider className="!my-2" />
|
||||
</>
|
||||
)}
|
||||
<Typography className="!mb-1" level="body2">
|
||||
Name<span className="text-red-600">*</span>
|
||||
</Typography>
|
||||
@ -165,6 +275,11 @@ const CreateIdentityProviderDialog: React.FC<Props> = (props: Props) => {
|
||||
<Divider className="!my-2" />
|
||||
{type === "OAUTH2" && (
|
||||
<>
|
||||
{isCreating && (
|
||||
<Alert variant="outlined" color="neutral" className="w-full mb-2">
|
||||
Redirect URL: {absolutifyLink("/auth/callback")}
|
||||
</Alert>
|
||||
)}
|
||||
<Typography className="!mb-1" level="body2">
|
||||
Client ID<span className="text-red-600">*</span>
|
||||
</Typography>
|
||||
|
@ -25,6 +25,14 @@ export function signin(username: string, password: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function signinWithSSO(identityProviderId: IdentityProviderId, code: string, redirectUri: string) {
|
||||
return axios.post<ResponseObject<User>>("/api/auth/signin/sso", {
|
||||
identityProviderId,
|
||||
code,
|
||||
redirectUri,
|
||||
});
|
||||
}
|
||||
|
||||
export function signup(username: string, password: string) {
|
||||
return axios.post<ResponseObject<User>>("/api/auth/signup", {
|
||||
username,
|
||||
|
@ -1,5 +1,5 @@
|
||||
.page-wrapper.auth {
|
||||
@apply flex flex-row justify-center items-center w-full h-full bg-zinc-100 dark:bg-zinc-800;
|
||||
@apply flex flex-row justify-center items-center w-full h-full dark:bg-zinc-800;
|
||||
|
||||
> .page-container {
|
||||
@apply w-80 max-w-full h-full py-4 flex flex-col justify-start items-center ml-calc;
|
||||
@ -37,7 +37,7 @@
|
||||
@apply absolute top-3 left-3 px-1 leading-10 flex-shrink-0 text-base cursor-text text-gray-400 bg-transparent transition-all select-none pointer-events-none;
|
||||
|
||||
&.not-null {
|
||||
@apply text-sm top-0 z-10 leading-4 bg-zinc-100 dark:bg-zinc-800 rounded;
|
||||
@apply text-sm top-0 z-10 leading-4 bg-white dark:bg-zinc-800 rounded;
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,7 +45,7 @@
|
||||
@apply py-2;
|
||||
|
||||
> input {
|
||||
@apply w-full py-3 px-3 text-base rounded-lg bg-zinc-100 dark:bg-zinc-800;
|
||||
@apply w-full py-3 px-3 text-base rounded-lg dark:bg-zinc-800;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { Button, Divider } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useGlobalStore, useUserStore } from "../store/module";
|
||||
import * as api from "../helpers/api";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import { validate, ValidatorConfig } from "../helpers/validator";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import Icon from "../components/Icon";
|
||||
@ -20,7 +21,6 @@ const validateConfig: ValidatorConfig = {
|
||||
|
||||
const Auth = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const globalStore = useGlobalStore();
|
||||
const userStore = useUserStore();
|
||||
const actionBtnLoadingState = useLoading(false);
|
||||
@ -28,9 +28,17 @@ const Auth = () => {
|
||||
const mode = systemStatus.profile.mode;
|
||||
const [username, setUsername] = useState(mode === "dev" ? "demohero" : "");
|
||||
const [password, setPassword] = useState(mode === "dev" ? "secret" : "");
|
||||
const [identityProviderList, setIdentityProviderList] = useState<IdentityProvider[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
userStore.doSignOut().catch();
|
||||
const fetchIdentityProviderList = async () => {
|
||||
const {
|
||||
data: { data: identityProviderList },
|
||||
} = await api.getIdentityProviderList();
|
||||
setIdentityProviderList(identityProviderList);
|
||||
};
|
||||
fetchIdentityProviderList();
|
||||
}, []);
|
||||
|
||||
const handleUsernameInputChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@ -73,7 +81,7 @@ const Auth = () => {
|
||||
await api.signin(username, password);
|
||||
const user = await userStore.doSignIn();
|
||||
if (user) {
|
||||
navigate("/");
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
toastHelper.error(t("message.login-failed"));
|
||||
}
|
||||
@ -106,7 +114,7 @@ const Auth = () => {
|
||||
await api.signup(username, password);
|
||||
const user = await userStore.doSignIn();
|
||||
if (user) {
|
||||
navigate("/");
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
toastHelper.error(t("common.singup-failed"));
|
||||
}
|
||||
@ -123,6 +131,20 @@ const Auth = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignInWithIdentityProvider = async (identityProvider: IdentityProvider) => {
|
||||
const stateQueryParameter = `auth.signin.${identityProvider.name}-${identityProvider.id}`;
|
||||
if (identityProvider.type === "OAUTH2") {
|
||||
const redirectUri = absolutifyLink("/auth/callback");
|
||||
const oauth2Config = identityProvider.config.oauth2Config;
|
||||
const authUrl = `${oauth2Config.authUrl}?client_id=${
|
||||
oauth2Config.clientId
|
||||
}&redirect_uri=${redirectUri}&state=${stateQueryParameter}&response_type=code&scope=${encodeURIComponent(
|
||||
oauth2Config.scopes.join(" ")
|
||||
)}`;
|
||||
window.location.href = authUrl;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-wrapper auth">
|
||||
<div className="page-container">
|
||||
@ -175,6 +197,25 @@ const Auth = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{identityProviderList.length > 0 && (
|
||||
<>
|
||||
<Divider className="!my-4">or</Divider>
|
||||
<div className="w-full flex flex-col space-y-2">
|
||||
{identityProviderList.map((identityProvider) => (
|
||||
<Button
|
||||
key={identityProvider.id}
|
||||
variant="outlined"
|
||||
color="neutral"
|
||||
className="w-full"
|
||||
size="md"
|
||||
onClick={() => handleSignInWithIdentityProvider(identityProvider)}
|
||||
>
|
||||
Sign in with {identityProvider.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!systemStatus?.host && <p className="tip-text">{t("auth.host-tip")}</p>}
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-center w-full gap-2">
|
||||
|
74
web/src/pages/AuthCallback.tsx
Normal file
74
web/src/pages/AuthCallback.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { last } from "lodash-es";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import * as api from "../helpers/api";
|
||||
import toastHelper from "../components/Toast";
|
||||
import { absolutifyLink } from "../helpers/utils";
|
||||
import { useUserStore } from "../store/module";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
interface State {
|
||||
loading: boolean;
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
const AuthCallback = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const userStore = useUserStore();
|
||||
const [state, setState] = useState<State>({
|
||||
loading: true,
|
||||
errorMessage: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
|
||||
if (code && state) {
|
||||
const redirectUri = absolutifyLink("/auth/callback");
|
||||
const identityProviderId = Number(last(state.split("-")));
|
||||
if (identityProviderId) {
|
||||
api
|
||||
.signinWithSSO(identityProviderId, code, redirectUri)
|
||||
.then(async () => {
|
||||
setState({
|
||||
loading: false,
|
||||
errorMessage: "",
|
||||
});
|
||||
const user = await userStore.doSignIn();
|
||||
if (user) {
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
toastHelper.error(t("message.login-failed"));
|
||||
}
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.error(error);
|
||||
setState({
|
||||
loading: false,
|
||||
errorMessage: JSON.stringify(error.response.data, null, 2),
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState({
|
||||
loading: false,
|
||||
errorMessage: "Failed to authorize. Invalid state passed to the auth callback.",
|
||||
});
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full h-full flex justify-center items-center">
|
||||
{state.loading ? (
|
||||
<Icon.Loader className="animate-spin dark:text-gray-200" />
|
||||
) : (
|
||||
<div className="max-w-lg font-mono whitespace-pre-wrap opacity-80">{state.errorMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthCallback;
|
@ -5,6 +5,7 @@ import store from "../store";
|
||||
import { initialGlobalState, initialUserState } from "../store/module";
|
||||
|
||||
const Auth = lazy(() => import("../pages/Auth"));
|
||||
const AuthCallback = lazy(() => import("../pages/AuthCallback"));
|
||||
const Explore = lazy(() => import("../pages/Explore"));
|
||||
const Home = lazy(() => import("../pages/Home"));
|
||||
const MemoDetail = lazy(() => import("../pages/MemoDetail"));
|
||||
@ -36,6 +37,10 @@ const router = createBrowserRouter([
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/auth/callback",
|
||||
element: <AuthCallback />,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <Home />,
|
||||
|
@ -47,7 +47,7 @@
|
||||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.18.6"
|
||||
|
||||
"@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.6", "@babel/runtime@^7.20.7", "@babel/runtime@^7.9.2":
|
||||
"@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.9.2":
|
||||
version "7.20.13"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.13.tgz#7055ab8a7cff2b8f6058bf6ae45ff84ad2aded4b"
|
||||
integrity sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==
|
||||
@ -355,70 +355,70 @@
|
||||
"@jridgewell/resolve-uri" "3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "1.4.14"
|
||||
|
||||
"@mui/base@5.0.0-alpha.115":
|
||||
version "5.0.0-alpha.115"
|
||||
resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-alpha.115.tgz#582b147fda56fe52d561fe9f64406e036d882338"
|
||||
integrity sha512-OGQ84whT/yNYd6xKCGGS6MxqEfjVjk5esXM7HP6bB2Rim7QICUapxZt4nm8q39fpT08rNDkv3xPVqDDwRdRg1g==
|
||||
"@mui/base@5.0.0-alpha.118":
|
||||
version "5.0.0-alpha.118"
|
||||
resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-alpha.118.tgz#335e7496ea605c9b7bda4164efb2da3f09f36dfc"
|
||||
integrity sha512-GAEpqhnuHjRaAZLdxFNuOf2GDTp9sUawM46oHZV4VnYPFjXJDkIYFWfIQLONb0nga92OiqS5DD/scGzVKCL0Mw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.20.7"
|
||||
"@babel/runtime" "^7.20.13"
|
||||
"@emotion/is-prop-valid" "^1.2.0"
|
||||
"@mui/types" "^7.2.3"
|
||||
"@mui/utils" "^5.11.2"
|
||||
"@mui/utils" "^5.11.9"
|
||||
"@popperjs/core" "^2.11.6"
|
||||
clsx "^1.2.1"
|
||||
prop-types "^15.8.1"
|
||||
react-is "^18.2.0"
|
||||
|
||||
"@mui/core-downloads-tracker@^5.11.6":
|
||||
version "5.11.6"
|
||||
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.11.6.tgz#79a60c0d95a08859cccd62a8d9a5336ef477a840"
|
||||
integrity sha512-lbD3qdafBOf2dlqKhOcVRxaPAujX+9UlPC6v8iMugMeAXe0TCgU3QbGXY3zrJsu6ex64WYDpH4y1+WOOBmWMuA==
|
||||
"@mui/core-downloads-tracker@^5.11.9":
|
||||
version "5.11.9"
|
||||
resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.11.9.tgz#0d3b20c2ef7704537c38597f9ecfc1894fe7c367"
|
||||
integrity sha512-YGEtucQ/Nl91VZkzYaLad47Cdui51n/hW+OQm4210g4N3/nZzBxmGeKfubEalf+ShKH4aYDS86XTO6q/TpZnjQ==
|
||||
|
||||
"@mui/joy@^5.0.0-alpha.63":
|
||||
version "5.0.0-alpha.64"
|
||||
resolved "https://registry.yarnpkg.com/@mui/joy/-/joy-5.0.0-alpha.64.tgz#e60d7ff9ba07b780f1726622cc99d67025fac38a"
|
||||
integrity sha512-IC5/pRbkn0J0QtbkKDPU3mpqUZOQL4uC/N8E831p1wS78xoZUxTr2PXLtOXIpbOuadZjzMeC46+urvFObMl9ZQ==
|
||||
"@mui/joy@^5.0.0-alpha.67":
|
||||
version "5.0.0-alpha.67"
|
||||
resolved "https://registry.yarnpkg.com/@mui/joy/-/joy-5.0.0-alpha.67.tgz#b9a0a15e82eb8a810b297a29d9e0c500fc3dbd6e"
|
||||
integrity sha512-Hol7tYXtSPcl1pApn6fpVdr2NFbftlXWaP5ql2AJ2VGo/MfInIatHYBR+QtGbn+XLDuOnhSYh/wHDL9u3xzlaQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.20.7"
|
||||
"@mui/base" "5.0.0-alpha.115"
|
||||
"@mui/core-downloads-tracker" "^5.11.6"
|
||||
"@mui/system" "^5.11.5"
|
||||
"@babel/runtime" "^7.20.13"
|
||||
"@mui/base" "5.0.0-alpha.118"
|
||||
"@mui/core-downloads-tracker" "^5.11.9"
|
||||
"@mui/system" "^5.11.9"
|
||||
"@mui/types" "^7.2.3"
|
||||
"@mui/utils" "^5.11.2"
|
||||
"@mui/utils" "^5.11.9"
|
||||
clsx "^1.2.1"
|
||||
csstype "^3.1.1"
|
||||
prop-types "^15.8.1"
|
||||
react-is "^18.2.0"
|
||||
|
||||
"@mui/private-theming@^5.11.2":
|
||||
version "5.11.2"
|
||||
resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.11.2.tgz#93eafb317070888a988efa8d6a9ec1f69183a606"
|
||||
integrity sha512-qZwMaqRFPwlYmqwVKblKBGKtIjJRAj3nsvX93pOmatsXyorW7N/0IPE/swPgz1VwChXhHO75DwBEx8tB+aRMNg==
|
||||
"@mui/private-theming@^5.11.9":
|
||||
version "5.11.9"
|
||||
resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.11.9.tgz#ce3f7b7fa7de3e8d6b2a3132a22bffd6bfaabe9b"
|
||||
integrity sha512-XMyVIFGomVCmCm92EvYlgq3zrC9K+J6r7IKl/rBJT2/xVYoRY6uM7jeB+Wxh7kXxnW9Dbqsr2yL3cx6wSD1sAg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.20.7"
|
||||
"@mui/utils" "^5.11.2"
|
||||
"@babel/runtime" "^7.20.13"
|
||||
"@mui/utils" "^5.11.9"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/styled-engine@^5.11.0":
|
||||
version "5.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.11.0.tgz#79afb30c612c7807c4b77602cf258526d3997c7b"
|
||||
integrity sha512-AF06K60Zc58qf0f7X+Y/QjaHaZq16znliLnGc9iVrV/+s8Ln/FCoeNuFvhlCbZZQ5WQcJvcy59zp0nXrklGGPQ==
|
||||
"@mui/styled-engine@^5.11.9":
|
||||
version "5.11.9"
|
||||
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.11.9.tgz#105da848163b993522de0deaada82e10ad357194"
|
||||
integrity sha512-bkh2CjHKOMy98HyOc8wQXEZvhOmDa/bhxMUekFX5IG0/w4f5HJ8R6+K6nakUUYNEgjOWPYzNPrvGB8EcGbhahQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.20.6"
|
||||
"@babel/runtime" "^7.20.13"
|
||||
"@emotion/cache" "^11.10.5"
|
||||
csstype "^3.1.1"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/system@^5.11.5":
|
||||
version "5.11.5"
|
||||
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.11.5.tgz#c880199634708c866063396f88d3fdd4c1dfcb48"
|
||||
integrity sha512-KNVsJ0sgRRp2XBqhh4wPS5aacteqjwxgiYTVwVnll2fgkgunZKo3DsDiGMrFlCg25ZHA3Ax58txWGE9w58zp0w==
|
||||
"@mui/system@^5.11.9":
|
||||
version "5.11.9"
|
||||
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.11.9.tgz#61f83c538cb4bb9383bcfb39734d9d22ae11c3e7"
|
||||
integrity sha512-h6uarf+l3FO6l75Nf7yO+qDGrIoa1DM9nAMCUFZQsNCDKOInRzcptnm8M1w/Z3gVetfeeGoIGAYuYKbft6KZZA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.20.7"
|
||||
"@mui/private-theming" "^5.11.2"
|
||||
"@mui/styled-engine" "^5.11.0"
|
||||
"@babel/runtime" "^7.20.13"
|
||||
"@mui/private-theming" "^5.11.9"
|
||||
"@mui/styled-engine" "^5.11.9"
|
||||
"@mui/types" "^7.2.3"
|
||||
"@mui/utils" "^5.11.2"
|
||||
"@mui/utils" "^5.11.9"
|
||||
clsx "^1.2.1"
|
||||
csstype "^3.1.1"
|
||||
prop-types "^15.8.1"
|
||||
@ -428,12 +428,12 @@
|
||||
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.3.tgz#06faae1c0e2f3a31c86af6f28b3a4a42143670b9"
|
||||
integrity sha512-tZ+CQggbe9Ol7e/Fs5RcKwg/woU+o8DCtOnccX6KmbBc7YrfqMYEYuaIcXHuhpT880QwNkZZ3wQwvtlDFA2yOw==
|
||||
|
||||
"@mui/utils@^5.11.2":
|
||||
version "5.11.2"
|
||||
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.11.2.tgz#29764311acb99425159b159b1cb382153ad9be1f"
|
||||
integrity sha512-AyizuHHlGdAtH5hOOXBW3kriuIwUIKUIgg0P7LzMvzf6jPhoQbENYqY6zJqfoZ7fAWMNNYT8mgN5EftNGzwE2w==
|
||||
"@mui/utils@^5.11.9":
|
||||
version "5.11.9"
|
||||
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.11.9.tgz#8fab9cf773c63ad916597921860d2344b5d4b706"
|
||||
integrity sha512-eOJaqzcEs4qEwolcvFAmXGpln+uvouvOS9FUX6Wkrte+4I8rZbjODOBDVNlK+V6/ziTfD4iNKC0G+KfOTApbqg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.20.7"
|
||||
"@babel/runtime" "^7.20.13"
|
||||
"@types/prop-types" "^15.7.5"
|
||||
"@types/react-is" "^16.7.1 || ^17.0.0"
|
||||
prop-types "^15.8.1"
|
||||
|
Reference in New Issue
Block a user