3
0

871 lines
22 KiB
Go

package service
import (
"context"
"fmt"
"regexp"
"strconv"
"time"
"github.com/markbates/goth"
"github.com/pkg/errors"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"golang.org/x/crypto/bcrypt"
"github.com/cortezaproject/corteza-server/pkg/logger"
"github.com/cortezaproject/corteza-server/pkg/permissions"
"github.com/cortezaproject/corteza-server/pkg/rand"
"github.com/cortezaproject/corteza-server/system/repository"
"github.com/cortezaproject/corteza-server/system/types"
)
type (
auth struct {
db db
ctx context.Context
logger *zap.Logger
subscription authSubscriptionChecker
credentials repository.CredentialsRepository
users repository.UserRepository
roles repository.RoleRepository
settings *types.Settings
notifications AuthNotificationService
providerValidator func(string) error
now func() *time.Time
}
AuthService interface {
With(ctx context.Context) AuthService
External(profile goth.User) (*types.User, error)
FrontendRedirectURL() string
InternalSignUp(input *types.User, password string) (*types.User, error)
InternalLogin(email string, password string) (*types.User, error)
SetPassword(userID uint64, newPassword string) error
ChangePassword(userID uint64, oldPassword, newPassword string) error
IssueAuthRequestToken(user *types.User) (token string, err error)
ValidateAuthRequestToken(token string) (user *types.User, err error)
ValidateEmailConfirmationToken(token string) (user *types.User, err error)
ExchangePasswordResetToken(token string) (user *types.User, exchangedToken string, err error)
ValidatePasswordResetToken(token string) (user *types.User, err error)
SendEmailAddressConfirmationToken(email string) (err error)
SendPasswordResetToken(email string) (err error)
CanRegister() error
LoadRoleMemberships(*types.User) error
checkPasswordStrength(string) error
changePassword(uint64, string) error
}
authSubscriptionChecker interface {
CanRegister(uint) error
}
)
const (
credentialsTypePassword = "password"
credentialsTypeEmailAuthToken = "email-authentication-token"
credentialsTypeResetPasswordToken = "password-reset-token"
credentialsTypeResetPasswordTokenExchanged = "password-reset-token-exchanged"
credentialsTypeAuthToken = "auth-token"
credentialsTokenLength = 32
)
var (
reEmail = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
)
func defaultProviderValidator(provider string) error {
_, err := goth.GetProvider(provider)
return err
}
func Auth(ctx context.Context) AuthService {
return (&auth{
logger: DefaultLogger.Named("auth"),
}).With(ctx)
}
func (svc auth) With(ctx context.Context) AuthService {
db := repository.DB(ctx)
return &auth{
db: db,
ctx: ctx,
logger: logger.AddRequestID(ctx, svc.logger),
credentials: repository.Credentials(ctx, db),
users: repository.User(ctx, db),
roles: repository.Role(ctx, db),
subscription: CurrentSubscription,
settings: CurrentSettings,
notifications: DefaultAuthNotification,
providerValidator: defaultProviderValidator,
now: func() *time.Time {
var now = time.Now()
return &now
},
}
}
// log() returns zap's logger with requestID from current context and fields.
func (svc auth) log(ctx context.Context, fields ...zapcore.Field) *zap.Logger {
return logger.AddRequestID(ctx, svc.logger).With(fields...)
}
// External func performs login/signup procedures
//
// We fully trust external auth sources (see system/auth/external) to provide a valid & validates
// profile (goth.User) that we use for user discovery and/or creation
//
// Flow
// 1. check for existing credentials using profile provider & provider's user ID
// 1.1. find existing local -or- "shadow" user
// 1.2. if user exists and is valid, update credentials (last-used-at) and complete the procedure
//
// 2. check for existing users using email from the profile
// 2.1. validate existing user -or-
// 2.2. create user on-the-fly if it does not exist
// 2.3. create credentials for that social login
//
func (svc auth) External(profile goth.User) (u *types.User, err error) {
if !svc.settings.Auth.External.Enabled {
return nil, errors.New("external authentication disabled")
}
if err = svc.providerValidator(profile.Provider); err != nil {
return nil, err
}
if profile.Email == "" {
return nil, errors.New("can not use profile data without an email")
}
log := svc.log(svc.ctx, zap.String("provider", profile.Provider))
return u, svc.db.Transaction(func() error {
var c *types.Credentials
if cc, err := svc.credentials.FindByCredentials(profile.Provider, profile.UserID); err == nil {
// Credentials found, load user
for _, c := range cc {
if !c.Valid() {
continue
}
if u, err = svc.users.FindByID(c.OwnerID); err != nil {
if repository.ErrUserNotFound.Eq(err) {
// Orphaned credentials (no owner)
// try to auto-fix this by removing credentials and recreating user
if err := svc.credentials.DeleteByID(c.ID); err != nil {
return errors.Wrap(err, "could not cleanup orphaned credentials")
} else {
goto findByEmail
}
}
return err
} else if u.Valid() {
// Valid user, Bingo!
c.LastUsedAt = svc.now()
if c, err = svc.credentials.Update(c); err != nil {
return err
}
log.Info("updating credentials entry for existing user",
zap.Uint64("credentialsID", c.ID),
zap.Uint64("userID", u.ID),
zap.String("email", u.Email),
)
return nil
} else {
// Scenario: linked to an invalid user
u = nil
continue
}
}
// If we could not find anything useful,
// we can search for user via email
} else {
// A serious error occurred, bail out...
return err
}
findByEmail:
// Find user via his email
if u, err = svc.users.FindByEmail(profile.Email); repository.ErrUserNotFound.Eq(err) {
// @todo check if it is ok to auto-create a user here
// In case we do not have this email, create a new user
u = &types.User{
Email: profile.Email,
Name: profile.Name,
Username: profile.NickName,
Handle: profile.NickName,
}
if err = svc.CanRegister(); err != nil {
return err
}
if u, err = svc.users.Create(u); err != nil {
return errors.Wrap(err, "could not create user after successful external authentication")
}
log.Info("created new user after successful social auth",
zap.Uint64("userID", u.ID),
zap.String("email", u.Email),
)
_ = svc.autoPromote(u)
} else if err != nil {
return err
} else if !u.Valid() {
return errors.Errorf("user not valid")
} else {
log.Info("existing user authenticated",
zap.Uint64("userID", u.ID),
zap.String("email", u.Email),
)
}
c = &types.Credentials{
Kind: profile.Provider,
OwnerID: u.ID,
Credentials: profile.UserID,
LastUsedAt: svc.now(),
}
if c, err = svc.credentials.Create(c); err != nil {
return err
}
log.Info("new credentials created for existing user",
zap.Uint64("credentialsID", c.ID),
zap.Uint64("userID", u.ID),
zap.String("email", u.Email),
)
// Owner loaded, carry on.
return nil
})
}
// FrontendRedirectURL - a proxy to frontend redirect url setting
func (svc auth) FrontendRedirectURL() string {
return svc.settings.Auth.Frontend.Url.Redirect
}
// InternalSignUp protocol
//
// Forgiving but strict: valid existing users get notified
//
// We're accepting the whole user object here and copy all we need to the new user
func (svc auth) InternalSignUp(input *types.User, password string) (u *types.User, err error) {
if !svc.settings.Auth.Internal.Enabled {
return nil, errors.New("internal authentication disabled")
}
if !svc.settings.Auth.Internal.Signup.Enabled {
return nil, errors.New("internal signup disabled")
}
if input == nil {
return nil, errors.New("invalid signup input")
}
if err = svc.validateInternalSignUp(input.Email); err != nil {
return
}
existing, err := svc.users.FindByEmail(input.Email)
if err == nil && existing.Valid() {
if len(password) == 0 {
return nil, errors.New("invalid username/password combination")
}
cc, err := svc.credentials.FindByKind(existing.ID, credentialsTypePassword)
if err != nil {
return nil, errors.Wrap(err, "could not find credentials")
}
err = svc.checkPassword(password, cc)
if err != nil {
return nil, errors.Wrap(err, "user with this email already exists")
}
if !existing.EmailConfirmed {
err = svc.sendEmailAddressConfirmationToken(existing)
if err != nil {
return nil, err
}
}
return existing, nil
// if !svc.settings.internalSignUpSendEmailOnExisting {
// return nil,errors.Wrap(err, "user with this email already exists")
// }
// User already exists, but we're nice and we'll send this user an
// email that will help him to login
// if !u.Valid() {
// return nil,errors.New("could not validate the user")
// }
//
// return nil,nil
} else if !repository.ErrUserNotFound.Eq(err) {
return nil, errors.Wrap(err, "could not check existing emails")
}
if err = svc.CanRegister(); err != nil {
return nil, err
}
// Whitelisted user data to copy
u, err = svc.users.Create(&types.User{
Email: input.Email,
Name: input.Name,
Username: input.Username,
Handle: input.Handle,
// Do we need confirmed email?
EmailConfirmed: !svc.settings.Auth.Internal.Signup.EmailConfirmationRequired,
})
if err != nil {
return nil, errors.Wrap(err, "could not create user")
}
_ = svc.autoPromote(u)
if len(password) > 0 {
err = svc.changePassword(u.ID, password)
if err != nil {
return nil, err
}
}
if !u.EmailConfirmed {
err = svc.sendEmailAddressConfirmationToken(u)
if err != nil {
return nil, err
}
}
if err != nil {
return nil, err
}
return u, nil
}
func (svc auth) validateInternalSignUp(email string) (err error) {
if !reEmail.MatchString(email) {
return errors.New("invalid email format")
}
return nil
}
// InternalLogin verifies username/password combination in the internal credentials table
//
// Expects plain text password as an input
func (svc auth) InternalLogin(email string, password string) (u *types.User, err error) {
if !svc.settings.Auth.Internal.Enabled {
return nil, errors.New("internal authentication disabled")
}
if err = svc.validateInternalLogin(email, password); err != nil {
return
}
err = svc.db.Transaction(func() error {
var (
cc types.CredentialsSet
)
u, err = svc.users.FindByEmail(email)
if repository.ErrUserNotFound.Eq(err) {
return errors.New("invalid username/password combination")
}
if err != nil {
return errors.Wrap(err, "could not find user")
}
cc, err := svc.credentials.FindByKind(u.ID, credentialsTypePassword)
if err != nil {
return errors.Wrap(err, "could not find credentials")
}
err = svc.checkPassword(password, cc)
if err != nil {
return err
}
return nil
})
if err != nil {
return
}
if !u.Valid() {
u = nil
err = errors.New("user not valid")
return
}
if !u.EmailConfirmed {
err = svc.sendEmailAddressConfirmationToken(u)
if err != nil {
return nil, err
}
return nil, errors.New("user email pending confirmation")
}
return u, err
}
// validateInternalLogin does basic format & length check
func (svc auth) validateInternalLogin(email string, password string) error {
if !reEmail.MatchString(email) {
return errors.Errorf("invalid email format, %s", email)
}
if len(password) == 0 {
return errors.New("empty password")
}
return nil
}
func (svc auth) checkPassword(password string, cc types.CredentialsSet) (err error) {
// We need only valid credentials (skip deleted, expired)
cc, _ = cc.Filter(func(c *types.Credentials) (b bool, e error) {
return c.Valid(), nil
})
for _, c := range cc {
if len(c.Credentials) == 0 {
continue
}
err = bcrypt.CompareHashAndPassword([]byte(c.Credentials), []byte(password))
if err == bcrypt.ErrMismatchedHashAndPassword {
// Mismatch, continue with the checking
continue
} else if err != nil {
// Some other error
return errors.Wrap(err, "could not compare passwords")
} else {
// Password matched one of credentials
return nil
}
}
return errors.New("invalid username/password combination")
}
// SetPassword sets new password for a user
func (svc auth) SetPassword(userID uint64, newPassword string) (err error) {
log := svc.log(svc.ctx, zap.Uint64("userID", userID))
if !svc.settings.Auth.Internal.Enabled {
return errors.New("internal authentication disabled")
}
if err = svc.checkPasswordStrength(newPassword); err != nil {
return
}
return svc.db.Transaction(func() error {
if err != svc.changePassword(userID, newPassword) {
return err
}
log.Info("password set")
return nil
})
}
// ChangePassword validates old password and changes it with new
func (svc auth) ChangePassword(userID uint64, oldPassword, newPassword string) (err error) {
log := svc.log(svc.ctx, zap.Uint64("userID", userID))
if !svc.settings.Auth.Internal.Enabled {
return errors.New("internal authentication disabled")
}
if len(oldPassword) == 0 {
return errors.New("old password missing")
}
if err = svc.checkPasswordStrength(newPassword); err != nil {
return
}
return svc.db.Transaction(func() error {
var (
cc types.CredentialsSet
)
cc, err = svc.credentials.FindByKind(userID, credentialsTypePassword)
if err != nil {
return errors.Wrap(err, "could not find credentials")
}
err = svc.checkPassword(oldPassword, cc)
if err != nil {
return errors.Wrap(err, "could not change password")
}
if err != svc.changePassword(userID, newPassword) {
return err
}
log.Info("password changed")
return nil
})
}
func (svc auth) hashPassword(password string) (hash []byte, err error) {
// @todo refactor and/or merge with user.hashPasssword()
hash, err = bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(err, "could not hash password")
}
return
}
func (svc auth) checkPasswordStrength(password string) error {
if len(password) <= 4 {
return errors.New("password too short")
}
// @todo proper strength checking
return nil
}
// ChangePassword (soft) deletes old password entry and creates a new one
//
// Expects hashed password as an input
func (svc auth) changePassword(userID uint64, password string) (err error) {
var hash []byte
if hash, err = svc.hashPassword(password); err != nil {
return err
}
if err = svc.credentials.DeleteByKind(userID, credentialsTypePassword); err != nil {
return errors.Wrap(err, "could not delete old credentials")
}
_, err = svc.credentials.Create(&types.Credentials{
OwnerID: userID,
Kind: credentialsTypePassword,
Credentials: string(hash),
})
return errors.Wrap(err, "could not create new password")
}
func (svc auth) IssueAuthRequestToken(user *types.User) (token string, err error) {
return svc.createUserToken(user, credentialsTypeAuthToken)
}
func (svc auth) ValidateAuthRequestToken(token string) (user *types.User, err error) {
return svc.loadUserFromToken(token, credentialsTypeAuthToken)
}
func (svc auth) ValidateEmailConfirmationToken(token string) (user *types.User, err error) {
if !svc.settings.Auth.Internal.Enabled {
return nil, errors.New("internal authentication disabled")
}
user, err = svc.loadUserFromToken(token, credentialsTypeEmailAuthToken)
if err != nil {
return nil, err
}
if !user.EmailConfirmed {
user.EmailConfirmed = true
svc.users.Update(user)
}
return
}
func (svc auth) ValidatePasswordResetToken(token string) (user *types.User, err error) {
if !svc.settings.Auth.Internal.Enabled {
return nil, errors.New("internal authentication disabled")
}
if !svc.settings.Auth.Internal.PasswordReset.Enabled {
return nil, errors.New("password reset disabled")
}
user, err = svc.loadUserFromToken(token, credentialsTypeResetPasswordTokenExchanged)
if err != nil {
return nil, err
}
if !user.EmailConfirmed {
// Confirm email while reseting password...
user.EmailConfirmed = true
svc.users.Update(user)
}
return
}
// ExchangePasswordResetToken exchanges reset password token for a new one and returns it with user info
func (svc auth) ExchangePasswordResetToken(token string) (user *types.User, exchangedToken string, err error) {
if !svc.settings.Auth.Internal.Enabled {
err = errors.New("internal authentication disabled")
return
}
if !svc.settings.Auth.Internal.PasswordReset.Enabled {
err = errors.New("password reset disabled")
return
}
user, err = svc.loadUserFromToken(token, credentialsTypeResetPasswordToken)
if err != nil {
user = nil
return
}
exchangedToken, err = svc.createUserToken(user, credentialsTypeResetPasswordTokenExchanged)
if err != nil {
user = nil
exchangedToken = ""
return
}
return
}
func (svc auth) SendEmailAddressConfirmationToken(email string) error {
if !svc.settings.Auth.Internal.Enabled {
return errors.New("internal authentication disabled")
}
u, err := svc.users.FindByEmail(email)
if err != nil {
return errors.Wrap(err, "could not load user")
}
return svc.sendEmailAddressConfirmationToken(u)
}
func (svc auth) sendEmailAddressConfirmationToken(u *types.User) (err error) {
log := svc.log(svc.ctx, zap.Uint64("userID", u.ID), zap.String("email", u.Email))
var (
notificationLang = "en"
token string
)
token, err = svc.createUserToken(u, credentialsTypeEmailAuthToken)
if err != nil {
return
}
err = svc.notifications.EmailConfirmation(notificationLang, u.Email, token)
if err != nil {
return errors.Wrap(err, "could not send email authentication notification")
}
log.With(zap.String("token", token)).Info("email address validation token sent")
return nil
}
func (svc auth) SendPasswordResetToken(email string) error {
if !svc.settings.Auth.Internal.Enabled {
return errors.New("internal authentication disabled")
}
if !svc.settings.Auth.Internal.PasswordReset.Enabled {
return errors.New("password reset disabled")
}
u, err := svc.users.FindByEmail(email)
if err != nil {
return errors.Wrap(err, "could not load user")
}
return svc.sendPasswordResetToken(u)
}
func (svc auth) CanRegister() error {
if svc.subscription != nil {
// When we have an active subscription, we need to check
// if users can register or did this deployment hit
// it's user-limit
return svc.subscription.CanRegister(svc.users.Total())
}
return nil
}
func (svc auth) sendPasswordResetToken(u *types.User) (err error) {
log := svc.log(svc.ctx, zap.Uint64("userID", u.ID), zap.String("email", u.Email))
var (
notificationLang = "en"
token string
)
token, err = svc.createUserToken(u, credentialsTypeResetPasswordToken)
if err != nil {
return
}
err = svc.notifications.PasswordReset(notificationLang, u.Email, token)
if err != nil {
return errors.Wrap(err, "could not send password reset notification")
}
log.With(zap.String("token", token)).Info("password reset token sent")
return nil
}
func (svc auth) loadUserFromToken(token, kind string) (u *types.User, err error) {
credentialsID, credentials, err := svc.validateToken(token)
if err != nil {
return
}
return u, svc.db.Transaction(func() error {
c, err := svc.credentials.FindByID(credentialsID)
if err == repository.ErrCredentialsNotFound {
return errors.New("no such token")
}
if err != nil {
return errors.Wrap(err, "could not load credentials")
}
if err = svc.credentials.DeleteByID(c.ID); err != nil {
return errors.Wrap(err, "could not remove credentials")
}
if !c.Valid() {
return errors.New("expired or invalid token")
}
if c.Credentials != credentials {
return errors.New("invalid token")
}
u, err = svc.users.FindByID(c.OwnerID)
if err != nil {
return errors.Wrap(err, "could not load user")
}
if !u.Valid() {
u = nil
return errors.New("user not valid")
}
return nil
})
}
func (svc auth) validateToken(token string) (ID uint64, credentials string, err error) {
// Token = <32 random chars><credentials-id>
if len(token) <= credentialsTokenLength {
err = errors.New("invalid token length")
return
}
ID, err = strconv.ParseUint(token[credentialsTokenLength:], 10, 64)
if err != nil {
err = errors.Wrap(err, "invalid token format")
return
}
if ID == 0 {
err = errors.New("invalid token ID")
return
}
credentials = token[:credentialsTokenLength]
return
}
func (svc auth) createUserToken(user *types.User, kind string) (token string, err error) {
var expiresAt time.Time
switch kind {
case credentialsTypeAuthToken:
// 15 sec expiration for all tokens that are part of redirction
expiresAt = svc.now().Add(time.Second * 15)
default:
// 1h expiration for all tokens send via email
expiresAt = svc.now().Add(time.Minute * 60)
}
c, err := svc.credentials.Create(&types.Credentials{
OwnerID: user.ID,
Kind: kind,
Credentials: string(rand.Bytes(credentialsTokenLength)),
ExpiresAt: &expiresAt,
})
if err != nil {
return
}
token = fmt.Sprintf("%s%d", c.Credentials, c.ID)
return
}
// Automatically promotes user to administrator if it is the first user in the database
func (svc auth) autoPromote(u *types.User) (err error) {
if svc.users.Total() == 1 && u.ID > 0 {
err = svc.roles.MemberAddByID(permissions.AdminsRoleID, u.ID)
svc.log(
svc.ctx,
zap.String("email", u.Email),
zap.Uint64("userID", u.ID),
zap.Error(err),
).Info("auto-promoted user to administrator role")
}
return
}
func (svc auth) LoadRoleMemberships(u *types.User) error {
rr, _, err := svc.roles.Find(types.RoleFilter{MemberID: u.ID})
if err != nil {
return err
}
u.SetRoles(rr.IDs())
return nil
}
var _ AuthService = &auth{}