4cddfcf2f3
Backend Tests / Tests (other) (push) Failing after 1s
Backend Tests / Static Checks (push) Failing after 2s
Backend Tests / Tests (internal) (push) Failing after 2s
Backend Tests / Tests (server) (push) Failing after 1s
Backend Tests / Tests (store) (push) Failing after 1s
Build Canary Image / build-frontend (push) Failing after 1s
Frontend Tests / Lint (push) Failing after 1s
Build Canary Image / build-push (linux/amd64) (push) Has been skipped
Build Canary Image / build-push (linux/arm64) (push) Has been skipped
Build Canary Image / merge (push) Has been skipped
Frontend Tests / Build (push) Failing after 1s
Release Please / release-please (push) Failing after 0s
Proto Linter / Lint Protos (push) Failing after 2s
247 lines
9.2 KiB
Go
247 lines
9.2 KiB
Go
package v1
|
|
|
|
import (
|
|
"context"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"github.com/usememos/memos/internal/idp"
|
|
"github.com/usememos/memos/internal/idp/oauth2"
|
|
"github.com/usememos/memos/internal/util"
|
|
storepb "github.com/usememos/memos/proto/gen/store"
|
|
"github.com/usememos/memos/store"
|
|
)
|
|
|
|
func (s *APIV1Service) resolveSSOUser(ctx context.Context, currentUser *store.User, identityProvider *storepb.IdentityProvider, userInfo *idp.IdentityProviderUserInfo) (*store.User, error) {
|
|
provider := identityProvider.Uid
|
|
externUID := userInfo.Identifier
|
|
|
|
user, err := s.getLinkedSSOUser(ctx, provider, externUID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user != nil {
|
|
if currentUser != nil && currentUser.ID != user.ID {
|
|
return nil, status.Errorf(codes.AlreadyExists, "identity provider account is already linked to another user")
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
if currentUser != nil {
|
|
return s.bindSSOIdentityToUser(ctx, currentUser, provider, externUID)
|
|
}
|
|
|
|
// Miss path: enforce the registration gate before creating anything.
|
|
instanceGeneralSetting, err := s.Store.GetInstanceGeneralSetting(ctx)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get instance general setting, error: %v", err)
|
|
}
|
|
if instanceGeneralSetting.DisallowUserRegistration {
|
|
return nil, status.Errorf(codes.PermissionDenied, "user registration is not allowed")
|
|
}
|
|
|
|
password, err := util.RandomString(20)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to generate random password, error: %v", err)
|
|
}
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to generate password hash, error: %v", err)
|
|
}
|
|
username, err := deriveSSOUsername()
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to derive username, error: %v", err)
|
|
}
|
|
user, err = s.Store.CreateUser(ctx, &store.User{
|
|
Username: username,
|
|
Role: store.RoleUser,
|
|
Nickname: userInfo.DisplayName,
|
|
Email: userInfo.Email,
|
|
AvatarURL: userInfo.AvatarURL,
|
|
PasswordHash: string(passwordHash),
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to create user, error: %v", err)
|
|
}
|
|
|
|
if _, err := s.Store.CreateUserIdentity(ctx, &store.UserIdentity{
|
|
UserID: user.ID,
|
|
Provider: provider,
|
|
ExternUID: externUID,
|
|
}); err != nil {
|
|
// Best-effort cleanup: the provisional user row has no linkage and should not remain.
|
|
_, _ = s.Store.DeleteUser(ctx, &store.DeleteUser{ID: user.ID})
|
|
if isUniqueConstraintViolation(err) {
|
|
// Concurrent first login won the race; load the winning linkage's user.
|
|
winner, getErr := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
|
|
Provider: &provider,
|
|
ExternUID: &externUID,
|
|
})
|
|
if getErr != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to reload user identity after race, error: %v", getErr)
|
|
}
|
|
if winner == nil {
|
|
return nil, status.Errorf(codes.Internal, "user identity conflict reported but no winning row found")
|
|
}
|
|
winnerUser, getErr := s.Store.GetUser(ctx, &store.FindUser{ID: &winner.UserID})
|
|
if getErr != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get user after race, error: %v", getErr)
|
|
}
|
|
if winnerUser == nil {
|
|
return nil, status.Errorf(codes.Internal, "linked user %d not found after race", winner.UserID)
|
|
}
|
|
return winnerUser, nil
|
|
}
|
|
return nil, status.Errorf(codes.Internal, "failed to create user identity, error: %v", err)
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *APIV1Service) resolveSSOIdentity(ctx context.Context, idpName, code, redirectURI, codeVerifier string) (*storepb.IdentityProvider, *idp.IdentityProviderUserInfo, error) {
|
|
idpUID, err := ExtractIdentityProviderUIDFromName(idpName)
|
|
if err != nil {
|
|
return nil, nil, status.Errorf(codes.InvalidArgument, "invalid identity provider name: %v", err)
|
|
}
|
|
identityProvider, err := s.Store.GetIdentityProvider(ctx, &store.FindIdentityProvider{
|
|
UID: &idpUID,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, status.Errorf(codes.Internal, "failed to get identity provider, error: %v", err)
|
|
}
|
|
if identityProvider == nil {
|
|
return nil, nil, status.Errorf(codes.InvalidArgument, "identity provider not found")
|
|
}
|
|
|
|
var userInfo *idp.IdentityProviderUserInfo
|
|
if identityProvider.Type == storepb.IdentityProvider_OAUTH2 {
|
|
oauth2IdentityProvider, err := oauth2.NewIdentityProvider(identityProvider.Config.GetOauth2Config())
|
|
if err != nil {
|
|
return nil, nil, status.Errorf(codes.Internal, "failed to create oauth2 identity provider, error: %v", err)
|
|
}
|
|
// Pass code_verifier for PKCE support (empty string if not provided for backward compatibility)
|
|
token, err := oauth2IdentityProvider.ExchangeToken(ctx, redirectURI, code, codeVerifier)
|
|
if err != nil {
|
|
return nil, nil, status.Errorf(codes.Internal, "failed to exchange token, error: %v", err)
|
|
}
|
|
userInfo, err = oauth2IdentityProvider.UserInfo(ctx, token)
|
|
if err != nil {
|
|
return nil, nil, status.Errorf(codes.Internal, "failed to get user info, error: %v", err)
|
|
}
|
|
}
|
|
|
|
identifierFilter := identityProvider.IdentifierFilter
|
|
if identifierFilter != "" {
|
|
identifierFilterRegex, err := regexp.Compile(identifierFilter)
|
|
if err != nil {
|
|
return nil, nil, status.Errorf(codes.Internal, "failed to compile identifier filter regex, error: %v", err)
|
|
}
|
|
if !identifierFilterRegex.MatchString(userInfo.Identifier) {
|
|
return nil, nil, status.Errorf(codes.PermissionDenied, "identifier %s is not allowed", userInfo.Identifier)
|
|
}
|
|
}
|
|
|
|
return identityProvider, userInfo, nil
|
|
}
|
|
|
|
func (s *APIV1Service) getLinkedSSOUser(ctx context.Context, provider, externUID string) (*store.User, error) {
|
|
identity, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
|
|
Provider: &provider,
|
|
ExternUID: &externUID,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get user identity, error: %v", err)
|
|
}
|
|
if identity == nil {
|
|
return nil, nil
|
|
}
|
|
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &identity.UserID})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get user, error: %v", err)
|
|
}
|
|
if user == nil {
|
|
return nil, status.Errorf(codes.Internal, "linked user %d not found for identity %d", identity.UserID, identity.ID)
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *APIV1Service) bindSSOIdentityToUser(ctx context.Context, currentUser *store.User, provider, externUID string) (*store.User, error) {
|
|
existingForProvider, err := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
|
|
UserID: ¤tUser.ID,
|
|
Provider: &provider,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to get existing linked identity, error: %v", err)
|
|
}
|
|
if existingForProvider != nil {
|
|
if existingForProvider.ExternUID == externUID {
|
|
return currentUser, nil
|
|
}
|
|
return nil, status.Errorf(codes.AlreadyExists, "identity provider is already linked to another external account for this user")
|
|
}
|
|
|
|
if _, err := s.Store.CreateUserIdentity(ctx, &store.UserIdentity{
|
|
UserID: currentUser.ID,
|
|
Provider: provider,
|
|
ExternUID: externUID,
|
|
}); err != nil {
|
|
if isUniqueConstraintViolation(err) {
|
|
winner, getErr := s.getLinkedSSOUser(ctx, provider, externUID)
|
|
if getErr != nil {
|
|
return nil, getErr
|
|
}
|
|
if winner != nil {
|
|
if winner.ID != currentUser.ID {
|
|
return nil, status.Errorf(codes.AlreadyExists, "identity provider account is already linked to another user")
|
|
}
|
|
return currentUser, nil
|
|
}
|
|
|
|
existingForProvider, getErr := s.Store.GetUserIdentity(ctx, &store.FindUserIdentity{
|
|
UserID: ¤tUser.ID,
|
|
Provider: &provider,
|
|
})
|
|
if getErr != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to reload linked identity after race, error: %v", getErr)
|
|
}
|
|
if existingForProvider != nil {
|
|
if existingForProvider.ExternUID == externUID {
|
|
return currentUser, nil
|
|
}
|
|
return nil, status.Errorf(codes.AlreadyExists, "identity provider is already linked to another external account for this user")
|
|
}
|
|
|
|
return nil, status.Errorf(codes.Internal, "user identity conflict reported but no winning row found")
|
|
}
|
|
return nil, status.Errorf(codes.Internal, "failed to create user identity, error: %v", err)
|
|
}
|
|
return currentUser, nil
|
|
}
|
|
|
|
// isUniqueConstraintViolation matches the driver-specific error messages that each
|
|
// supported backend emits when any UNIQUE constraint rejects an insert. Callers
|
|
// disambiguate which constraint was hit from the insertion context (e.g. inserting
|
|
// a user_identity row can only violate UNIQUE(provider, extern_uid); inserting a
|
|
// user row can only violate UNIQUE(username)). Matches the pattern used in
|
|
// memo_service.go for the memo UID unique check.
|
|
func isUniqueConstraintViolation(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := err.Error()
|
|
return strings.Contains(msg, "UNIQUE constraint failed") ||
|
|
strings.Contains(msg, "duplicate key") ||
|
|
strings.Contains(msg, "Duplicate entry")
|
|
}
|
|
|
|
// doSignIn performs the actual sign-in operation by creating a session and setting the cookie.
|
|
//
|
|
// This function:
|
|
// 1. Generates refresh token and access token.
|
|
// 2. Stores refresh token metadata in user_setting.
|
|
// 3. Sets refresh token as HttpOnly cookie.
|
|
// 4. Returns access token and its expiry time.
|