ec780003f0
The upgrade notification has been updated to adapt to mobile screens accordance with our designs. Additionally, an issue where the notification would display "0B free included" when displayed in the All Projects Dashboard has been fixed. Change-Id: Ic13b9426ab5d6529c9d7b2ad8446a17da74905b1
1082 lines
30 KiB
Go
1082 lines
30 KiB
Go
// Copyright (C) 2019 Storj Labs, Inc.
|
|
// See LICENSE for copying information.
|
|
|
|
package consoleapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/zeebo/errs"
|
|
"go.uber.org/zap"
|
|
|
|
"storj.io/common/uuid"
|
|
"storj.io/storj/private/post"
|
|
"storj.io/storj/private/web"
|
|
"storj.io/storj/satellite/analytics"
|
|
"storj.io/storj/satellite/console"
|
|
"storj.io/storj/satellite/console/consoleweb/consoleapi/utils"
|
|
"storj.io/storj/satellite/console/consoleweb/consolewebauth"
|
|
"storj.io/storj/satellite/mailservice"
|
|
)
|
|
|
|
var (
|
|
// ErrAuthAPI - console auth api error type.
|
|
ErrAuthAPI = errs.Class("consoleapi auth")
|
|
|
|
// errNotImplemented is the error value used by handlers of this package to
|
|
// response with status Not Implemented.
|
|
errNotImplemented = errs.New("not implemented")
|
|
)
|
|
|
|
// Auth is an api controller that exposes all auth functionality.
|
|
type Auth struct {
|
|
log *zap.Logger
|
|
ExternalAddress string
|
|
LetUsKnowURL string
|
|
TermsAndConditionsURL string
|
|
ContactInfoURL string
|
|
GeneralRequestURL string
|
|
PasswordRecoveryURL string
|
|
CancelPasswordRecoveryURL string
|
|
ActivateAccountURL string
|
|
SatelliteName string
|
|
service *console.Service
|
|
accountFreezeService *console.AccountFreezeService
|
|
analytics *analytics.Service
|
|
mailService *mailservice.Service
|
|
cookieAuth *consolewebauth.CookieAuth
|
|
}
|
|
|
|
// NewAuth is a constructor for api auth controller.
|
|
func NewAuth(log *zap.Logger, service *console.Service, accountFreezeService *console.AccountFreezeService, mailService *mailservice.Service, cookieAuth *consolewebauth.CookieAuth, analytics *analytics.Service, satelliteName string, externalAddress string, letUsKnowURL string, termsAndConditionsURL string, contactInfoURL string, generalRequestURL string) *Auth {
|
|
return &Auth{
|
|
log: log,
|
|
ExternalAddress: externalAddress,
|
|
LetUsKnowURL: letUsKnowURL,
|
|
TermsAndConditionsURL: termsAndConditionsURL,
|
|
ContactInfoURL: contactInfoURL,
|
|
GeneralRequestURL: generalRequestURL,
|
|
SatelliteName: satelliteName,
|
|
PasswordRecoveryURL: externalAddress + "password-recovery",
|
|
CancelPasswordRecoveryURL: externalAddress + "cancel-password-recovery",
|
|
ActivateAccountURL: externalAddress + "activation",
|
|
service: service,
|
|
accountFreezeService: accountFreezeService,
|
|
mailService: mailService,
|
|
cookieAuth: cookieAuth,
|
|
analytics: analytics,
|
|
}
|
|
}
|
|
|
|
// Token authenticates user by credentials and returns auth token.
|
|
func (a *Auth) Token(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
tokenRequest := console.AuthUser{}
|
|
err = json.NewDecoder(r.Body).Decode(&tokenRequest)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
tokenRequest.UserAgent = r.UserAgent()
|
|
tokenRequest.IP, err = web.GetRequestIP(r)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
tokenInfo, err := a.service.Token(ctx, tokenRequest)
|
|
if err != nil {
|
|
if console.ErrMFAMissing.Has(err) {
|
|
web.ServeCustomJSONError(a.log, w, http.StatusOK, err, a.getUserErrorMessage(err))
|
|
} else {
|
|
a.log.Info("Error authenticating token request", zap.String("email", tokenRequest.Email), zap.Error(ErrAuthAPI.Wrap(err)))
|
|
a.serveJSONError(w, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
a.cookieAuth.SetTokenCookie(w, *tokenInfo)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
err = json.NewEncoder(w).Encode(struct {
|
|
console.TokenInfo
|
|
Token string `json:"token"`
|
|
}{*tokenInfo, tokenInfo.Token.String()})
|
|
if err != nil {
|
|
a.log.Error("token handler could not encode token response", zap.Error(ErrAuthAPI.Wrap(err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// TokenByAPIKey authenticates user by API key and returns auth token.
|
|
func (a *Auth) TokenByAPIKey(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
authToken := r.Header.Get("Authorization")
|
|
if !(strings.HasPrefix(authToken, "Bearer ")) {
|
|
a.log.Info("authorization key format is incorrect. Should be 'Bearer <key>'")
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
apiKey := strings.TrimPrefix(authToken, "Bearer ")
|
|
|
|
userAgent := r.UserAgent()
|
|
ip, err := web.GetRequestIP(r)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
tokenInfo, err := a.service.TokenByAPIKey(ctx, userAgent, ip, apiKey)
|
|
if err != nil {
|
|
a.log.Info("Error authenticating token request", zap.Error(ErrAuthAPI.Wrap(err)))
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
a.cookieAuth.SetTokenCookie(w, *tokenInfo)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
err = json.NewEncoder(w).Encode(struct {
|
|
console.TokenInfo
|
|
Token string `json:"token"`
|
|
}{*tokenInfo, tokenInfo.Token.String()})
|
|
if err != nil {
|
|
a.log.Error("token handler could not encode token response", zap.Error(ErrAuthAPI.Wrap(err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// getSessionID gets the session ID from the request.
|
|
func (a *Auth) getSessionID(r *http.Request) (id uuid.UUID, err error) {
|
|
|
|
tokenInfo, err := a.cookieAuth.GetToken(r)
|
|
if err != nil {
|
|
return uuid.UUID{}, err
|
|
}
|
|
|
|
sessionID, err := uuid.FromBytes(tokenInfo.Token.Payload)
|
|
if err != nil {
|
|
return uuid.UUID{}, err
|
|
}
|
|
|
|
return sessionID, nil
|
|
}
|
|
|
|
// Logout removes auth cookie.
|
|
func (a *Auth) Logout(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
defer mon.Task()(&ctx)(nil)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
sessionID, err := a.getSessionID(r)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
err = a.service.DeleteSession(ctx, sessionID)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
a.cookieAuth.RemoveTokenCookie(w)
|
|
}
|
|
|
|
// Register creates new user, sends activation e-mail.
|
|
// If a user with the given e-mail address already exists, a password reset e-mail is sent instead.
|
|
func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
var registerData struct {
|
|
FullName string `json:"fullName"`
|
|
ShortName string `json:"shortName"`
|
|
Email string `json:"email"`
|
|
Partner string `json:"partner"`
|
|
UserAgent []byte `json:"userAgent"`
|
|
Password string `json:"password"`
|
|
SecretInput string `json:"secret"`
|
|
ReferrerUserID string `json:"referrerUserId"`
|
|
IsProfessional bool `json:"isProfessional"`
|
|
Position string `json:"position"`
|
|
CompanyName string `json:"companyName"`
|
|
StorageNeeds string `json:"storageNeeds"`
|
|
EmployeeCount string `json:"employeeCount"`
|
|
HaveSalesContact bool `json:"haveSalesContact"`
|
|
CaptchaResponse string `json:"captchaResponse"`
|
|
SignupPromoCode string `json:"signupPromoCode"`
|
|
}
|
|
|
|
err = json.NewDecoder(r.Body).Decode(®isterData)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
// trim leading and trailing spaces of email address.
|
|
registerData.Email = strings.TrimSpace(registerData.Email)
|
|
|
|
isValidEmail := utils.ValidateEmail(registerData.Email)
|
|
if !isValidEmail {
|
|
a.serveJSONError(w, console.ErrValidation.Wrap(errs.New("Invalid email.")))
|
|
return
|
|
}
|
|
|
|
if len([]rune(registerData.Partner)) > 100 {
|
|
a.serveJSONError(w, console.ErrValidation.Wrap(errs.New("Partner must be less than or equal to 100 characters")))
|
|
return
|
|
}
|
|
|
|
if len([]rune(registerData.SignupPromoCode)) > 100 {
|
|
a.serveJSONError(w, console.ErrValidation.Wrap(errs.New("Promo code must be less than or equal to 100 characters")))
|
|
return
|
|
}
|
|
|
|
verified, unverified, err := a.service.GetUserByEmailWithUnverified(ctx, registerData.Email)
|
|
if err != nil && !console.ErrEmailNotFound.Has(err) {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
if verified != nil {
|
|
satelliteAddress := a.ExternalAddress
|
|
if !strings.HasSuffix(satelliteAddress, "/") {
|
|
satelliteAddress += "/"
|
|
}
|
|
a.mailService.SendRenderedAsync(
|
|
ctx,
|
|
[]post.Address{{Address: verified.Email}},
|
|
&console.AccountAlreadyExistsEmail{
|
|
Origin: satelliteAddress,
|
|
SatelliteName: a.SatelliteName,
|
|
SignInLink: satelliteAddress + "login",
|
|
ResetPasswordLink: satelliteAddress + "forgot-password",
|
|
CreateAccountLink: satelliteAddress + "signup",
|
|
},
|
|
)
|
|
return
|
|
}
|
|
|
|
var user *console.User
|
|
if len(unverified) > 0 {
|
|
user = &unverified[0]
|
|
} else {
|
|
secret, err := console.RegistrationSecretFromBase64(registerData.SecretInput)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
if registerData.Partner != "" {
|
|
registerData.UserAgent = []byte(registerData.Partner)
|
|
}
|
|
|
|
ip, err := web.GetRequestIP(r)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
user, err = a.service.CreateUser(ctx,
|
|
console.CreateUser{
|
|
FullName: registerData.FullName,
|
|
ShortName: registerData.ShortName,
|
|
Email: registerData.Email,
|
|
UserAgent: registerData.UserAgent,
|
|
Password: registerData.Password,
|
|
IsProfessional: registerData.IsProfessional,
|
|
Position: registerData.Position,
|
|
CompanyName: registerData.CompanyName,
|
|
EmployeeCount: registerData.EmployeeCount,
|
|
HaveSalesContact: registerData.HaveSalesContact,
|
|
CaptchaResponse: registerData.CaptchaResponse,
|
|
IP: ip,
|
|
SignupPromoCode: registerData.SignupPromoCode,
|
|
},
|
|
secret,
|
|
)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
// see if referrer was provided in URL query, otherwise use the Referer header in the request.
|
|
referrer := r.URL.Query().Get("referrer")
|
|
if referrer == "" {
|
|
referrer = r.Referer()
|
|
}
|
|
hubspotUTK := ""
|
|
hubspotCookie, err := r.Cookie("hubspotutk")
|
|
if err == nil {
|
|
hubspotUTK = hubspotCookie.Value
|
|
}
|
|
|
|
trackCreateUserFields := analytics.TrackCreateUserFields{
|
|
ID: user.ID,
|
|
AnonymousID: loadSession(r),
|
|
FullName: user.FullName,
|
|
Email: user.Email,
|
|
Type: analytics.Personal,
|
|
OriginHeader: r.Header.Get("Origin"),
|
|
Referrer: referrer,
|
|
HubspotUTK: hubspotUTK,
|
|
UserAgent: string(user.UserAgent),
|
|
}
|
|
if user.IsProfessional {
|
|
trackCreateUserFields.Type = analytics.Professional
|
|
trackCreateUserFields.EmployeeCount = user.EmployeeCount
|
|
trackCreateUserFields.CompanyName = user.CompanyName
|
|
trackCreateUserFields.StorageNeeds = registerData.StorageNeeds
|
|
trackCreateUserFields.JobTitle = user.Position
|
|
trackCreateUserFields.HaveSalesContact = user.HaveSalesContact
|
|
}
|
|
a.analytics.TrackCreateUser(trackCreateUserFields)
|
|
}
|
|
|
|
token, err := a.service.GenerateActivationToken(ctx, user.ID, user.Email)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
link := a.ActivateAccountURL + "?token=" + token
|
|
|
|
a.mailService.SendRenderedAsync(
|
|
ctx,
|
|
[]post.Address{{Address: user.Email}},
|
|
&console.AccountActivationEmail{
|
|
ActivationLink: link,
|
|
Origin: a.ExternalAddress,
|
|
},
|
|
)
|
|
}
|
|
|
|
// loadSession looks for a cookie for the session id.
|
|
// this cookie is set from the reverse proxy if the user opts into cookies from Storj.
|
|
func loadSession(req *http.Request) string {
|
|
sessionCookie, err := req.Cookie("webtraf-sid")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return sessionCookie.Value
|
|
}
|
|
|
|
// GetFreezeStatus checks to see if an account is frozen or warned.
|
|
func (a *Auth) GetFreezeStatus(w http.ResponseWriter, r *http.Request) {
|
|
type FrozenResult struct {
|
|
Frozen bool `json:"frozen"`
|
|
Warned bool `json:"warned"`
|
|
}
|
|
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
userID, err := a.service.GetUserID(ctx)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
freeze, warning, err := a.accountFreezeService.GetAll(ctx, userID)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
err = json.NewEncoder(w).Encode(FrozenResult{
|
|
Frozen: freeze != nil,
|
|
Warned: warning != nil,
|
|
})
|
|
if err != nil {
|
|
a.log.Error("could not encode account status", zap.Error(ErrAuthAPI.Wrap(err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// UpdateAccount updates user's full name and short name.
|
|
func (a *Auth) UpdateAccount(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
var updatedInfo struct {
|
|
FullName string `json:"fullName"`
|
|
ShortName string `json:"shortName"`
|
|
}
|
|
|
|
err = json.NewDecoder(r.Body).Decode(&updatedInfo)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
if err = a.service.UpdateAccount(ctx, updatedInfo.FullName, updatedInfo.ShortName); err != nil {
|
|
a.serveJSONError(w, err)
|
|
}
|
|
}
|
|
|
|
// GetAccount gets authorized user and take it's params.
|
|
func (a *Auth) GetAccount(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
var user struct {
|
|
ID uuid.UUID `json:"id"`
|
|
FullName string `json:"fullName"`
|
|
ShortName string `json:"shortName"`
|
|
Email string `json:"email"`
|
|
Partner string `json:"partner"`
|
|
ProjectLimit int `json:"projectLimit"`
|
|
ProjectStorageLimit int64 `json:"projectStorageLimit"`
|
|
IsProfessional bool `json:"isProfessional"`
|
|
Position string `json:"position"`
|
|
CompanyName string `json:"companyName"`
|
|
EmployeeCount string `json:"employeeCount"`
|
|
HaveSalesContact bool `json:"haveSalesContact"`
|
|
PaidTier bool `json:"paidTier"`
|
|
MFAEnabled bool `json:"isMFAEnabled"`
|
|
MFARecoveryCodeCount int `json:"mfaRecoveryCodeCount"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
consoleUser, err := console.GetUser(ctx)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
user.ShortName = consoleUser.ShortName
|
|
user.FullName = consoleUser.FullName
|
|
user.Email = consoleUser.Email
|
|
user.ID = consoleUser.ID
|
|
if consoleUser.UserAgent != nil {
|
|
user.Partner = string(consoleUser.UserAgent)
|
|
}
|
|
user.ProjectLimit = consoleUser.ProjectLimit
|
|
user.ProjectStorageLimit = consoleUser.ProjectStorageLimit
|
|
user.IsProfessional = consoleUser.IsProfessional
|
|
user.CompanyName = consoleUser.CompanyName
|
|
user.Position = consoleUser.Position
|
|
user.EmployeeCount = consoleUser.EmployeeCount
|
|
user.HaveSalesContact = consoleUser.HaveSalesContact
|
|
user.PaidTier = consoleUser.PaidTier
|
|
user.MFAEnabled = consoleUser.MFAEnabled
|
|
user.MFARecoveryCodeCount = len(consoleUser.MFARecoveryCodes)
|
|
user.CreatedAt = consoleUser.CreatedAt
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
err = json.NewEncoder(w).Encode(&user)
|
|
if err != nil {
|
|
a.log.Error("could not encode user info", zap.Error(ErrAuthAPI.Wrap(err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// DeleteAccount authorizes user and deletes account by password.
|
|
func (a *Auth) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
defer mon.Task()(&ctx)(&errNotImplemented)
|
|
|
|
// We do not want to allow account deletion via API currently.
|
|
a.serveJSONError(w, errNotImplemented)
|
|
}
|
|
|
|
// ChangeEmail auth user, changes users email for a new one.
|
|
func (a *Auth) ChangeEmail(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
var emailChange struct {
|
|
NewEmail string `json:"newEmail"`
|
|
}
|
|
|
|
err = json.NewDecoder(r.Body).Decode(&emailChange)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
err = a.service.ChangeEmail(ctx, emailChange.NewEmail)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// ChangePassword auth user, changes users password for a new one.
|
|
func (a *Auth) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
var passwordChange struct {
|
|
CurrentPassword string `json:"password"`
|
|
NewPassword string `json:"newPassword"`
|
|
}
|
|
|
|
err = json.NewDecoder(r.Body).Decode(&passwordChange)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
err = a.service.ChangePassword(ctx, passwordChange.CurrentPassword, passwordChange.NewPassword)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// ForgotPassword creates password-reset token and sends email to user.
|
|
func (a *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
var forgotPassword struct {
|
|
Email string `json:"email"`
|
|
CaptchaResponse string `json:"captchaResponse"`
|
|
}
|
|
|
|
err = json.NewDecoder(r.Body).Decode(&forgotPassword)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
ip, err := web.GetRequestIP(r)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
valid, err := a.service.VerifyForgotPasswordCaptcha(ctx, forgotPassword.CaptchaResponse, ip)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
if !valid {
|
|
a.serveJSONError(w, console.ErrCaptcha.New("captcha validation unsuccessful"))
|
|
return
|
|
}
|
|
|
|
user, _, err := a.service.GetUserByEmailWithUnverified(ctx, forgotPassword.Email)
|
|
if err != nil || user == nil {
|
|
satelliteAddress := a.ExternalAddress
|
|
|
|
if !strings.HasSuffix(satelliteAddress, "/") {
|
|
satelliteAddress += "/"
|
|
}
|
|
resetPasswordLink := satelliteAddress + "forgot-password"
|
|
doubleCheckLink := satelliteAddress + "login"
|
|
createAccountLink := satelliteAddress + "signup"
|
|
|
|
a.mailService.SendRenderedAsync(
|
|
ctx,
|
|
[]post.Address{{Address: forgotPassword.Email, Name: ""}},
|
|
&console.UnknownResetPasswordEmail{
|
|
Satellite: a.SatelliteName,
|
|
Email: forgotPassword.Email,
|
|
DoubleCheckLink: doubleCheckLink,
|
|
ResetPasswordLink: resetPasswordLink,
|
|
CreateAnAccountLink: createAccountLink,
|
|
SupportTeamLink: a.GeneralRequestURL,
|
|
},
|
|
)
|
|
return
|
|
}
|
|
|
|
recoveryToken, err := a.service.GeneratePasswordRecoveryToken(ctx, user.ID)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
passwordRecoveryLink := a.PasswordRecoveryURL + "?token=" + recoveryToken
|
|
cancelPasswordRecoveryLink := a.CancelPasswordRecoveryURL + "?token=" + recoveryToken
|
|
userName := user.ShortName
|
|
if user.ShortName == "" {
|
|
userName = user.FullName
|
|
}
|
|
|
|
contactInfoURL := a.ContactInfoURL
|
|
letUsKnowURL := a.LetUsKnowURL
|
|
termsAndConditionsURL := a.TermsAndConditionsURL
|
|
|
|
a.mailService.SendRenderedAsync(
|
|
ctx,
|
|
[]post.Address{{Address: user.Email, Name: userName}},
|
|
&console.ForgotPasswordEmail{
|
|
Origin: a.ExternalAddress,
|
|
UserName: userName,
|
|
ResetLink: passwordRecoveryLink,
|
|
CancelPasswordRecoveryLink: cancelPasswordRecoveryLink,
|
|
LetUsKnowURL: letUsKnowURL,
|
|
ContactInfoURL: contactInfoURL,
|
|
TermsAndConditionsURL: termsAndConditionsURL,
|
|
},
|
|
)
|
|
}
|
|
|
|
// ResendEmail generates activation token by e-mail address and sends email account activation email to user.
|
|
// If the account is already activated, a password reset e-mail is sent instead.
|
|
func (a *Auth) ResendEmail(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
params := mux.Vars(r)
|
|
email, ok := params["email"]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
verified, unverified, err := a.service.GetUserByEmailWithUnverified(ctx, email)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if verified != nil {
|
|
recoveryToken, err := a.service.GeneratePasswordRecoveryToken(ctx, verified.ID)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
userName := verified.ShortName
|
|
if verified.ShortName == "" {
|
|
userName = verified.FullName
|
|
}
|
|
|
|
a.mailService.SendRenderedAsync(
|
|
ctx,
|
|
[]post.Address{{Address: verified.Email, Name: userName}},
|
|
&console.ForgotPasswordEmail{
|
|
Origin: a.ExternalAddress,
|
|
UserName: userName,
|
|
ResetLink: a.PasswordRecoveryURL + "?token=" + recoveryToken,
|
|
CancelPasswordRecoveryLink: a.CancelPasswordRecoveryURL + "?token=" + recoveryToken,
|
|
LetUsKnowURL: a.LetUsKnowURL,
|
|
ContactInfoURL: a.ContactInfoURL,
|
|
TermsAndConditionsURL: a.TermsAndConditionsURL,
|
|
},
|
|
)
|
|
return
|
|
}
|
|
|
|
user := unverified[0]
|
|
|
|
token, err := a.service.GenerateActivationToken(ctx, user.ID, user.Email)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
link := a.ActivateAccountURL + "?token=" + token
|
|
contactInfoURL := a.ContactInfoURL
|
|
termsAndConditionsURL := a.TermsAndConditionsURL
|
|
|
|
a.mailService.SendRenderedAsync(
|
|
ctx,
|
|
[]post.Address{{Address: user.Email}},
|
|
&console.AccountActivationEmail{
|
|
Origin: a.ExternalAddress,
|
|
ActivationLink: link,
|
|
TermsAndConditionsURL: termsAndConditionsURL,
|
|
ContactInfoURL: contactInfoURL,
|
|
},
|
|
)
|
|
}
|
|
|
|
// EnableUserMFA enables multi-factor authentication for the user.
|
|
func (a *Auth) EnableUserMFA(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
var data struct {
|
|
Passcode string `json:"passcode"`
|
|
}
|
|
err = json.NewDecoder(r.Body).Decode(&data)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
err = a.service.EnableUserMFA(ctx, data.Passcode, time.Now())
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
sessionID, err := a.getSessionID(r)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
consoleUser, err := console.GetUser(ctx)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
err = a.service.DeleteAllSessionsByUserIDExcept(ctx, consoleUser.ID, sessionID)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// DisableUserMFA disables multi-factor authentication for the user.
|
|
func (a *Auth) DisableUserMFA(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
var data struct {
|
|
Passcode string `json:"passcode"`
|
|
RecoveryCode string `json:"recoveryCode"`
|
|
}
|
|
err = json.NewDecoder(r.Body).Decode(&data)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
err = a.service.DisableUserMFA(ctx, data.Passcode, time.Now(), data.RecoveryCode)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
sessionID, err := a.getSessionID(r)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
consoleUser, err := console.GetUser(ctx)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
err = a.service.DeleteAllSessionsByUserIDExcept(ctx, consoleUser.ID, sessionID)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// GenerateMFASecretKey creates a new TOTP secret key for the user.
|
|
func (a *Auth) GenerateMFASecretKey(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
key, err := a.service.ResetMFASecretKey(ctx)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
err = json.NewEncoder(w).Encode(key)
|
|
if err != nil {
|
|
a.log.Error("could not encode MFA secret key", zap.Error(ErrAuthAPI.Wrap(err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// GenerateMFARecoveryCodes creates a new set of MFA recovery codes for the user.
|
|
func (a *Auth) GenerateMFARecoveryCodes(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
codes, err := a.service.ResetMFARecoveryCodes(ctx)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
err = json.NewEncoder(w).Encode(codes)
|
|
if err != nil {
|
|
a.log.Error("could not encode MFA recovery codes", zap.Error(ErrAuthAPI.Wrap(err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// ResetPassword resets user's password using recovery token.
|
|
func (a *Auth) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
var resetPassword struct {
|
|
RecoveryToken string `json:"token"`
|
|
NewPassword string `json:"password"`
|
|
MFAPasscode string `json:"mfaPasscode"`
|
|
MFARecoveryCode string `json:"mfaRecoveryCode"`
|
|
}
|
|
|
|
err = json.NewDecoder(r.Body).Decode(&resetPassword)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
}
|
|
|
|
err = a.service.ResetPassword(ctx, resetPassword.RecoveryToken, resetPassword.NewPassword, resetPassword.MFAPasscode, resetPassword.MFARecoveryCode, time.Now())
|
|
|
|
if console.ErrMFAMissing.Has(err) || console.ErrMFAPasscode.Has(err) || console.ErrMFARecoveryCode.Has(err) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(a.getStatusCode(err))
|
|
|
|
err = json.NewEncoder(w).Encode(map[string]string{
|
|
"error": a.getUserErrorMessage(err),
|
|
"code": "mfa_required",
|
|
})
|
|
|
|
if err != nil {
|
|
a.log.Error("failed to write json response", zap.Error(ErrUtils.Wrap(err)))
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if console.ErrTokenExpiration.Has(err) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(a.getStatusCode(err))
|
|
|
|
err = json.NewEncoder(w).Encode(map[string]string{
|
|
"error": a.getUserErrorMessage(err),
|
|
"code": "token_expired",
|
|
})
|
|
|
|
if err != nil {
|
|
a.log.Error("password-reset-token expired: failed to write json response", zap.Error(ErrUtils.Wrap(err)))
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
} else {
|
|
a.cookieAuth.RemoveTokenCookie(w)
|
|
}
|
|
}
|
|
|
|
// RefreshSession refreshes the user's session.
|
|
func (a *Auth) RefreshSession(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
tokenInfo, err := a.cookieAuth.GetToken(r)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
id, err := uuid.FromBytes(tokenInfo.Token.Payload)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
tokenInfo.ExpiresAt, err = a.service.RefreshSession(ctx, id)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
a.cookieAuth.SetTokenCookie(w, tokenInfo)
|
|
|
|
err = json.NewEncoder(w).Encode(tokenInfo.ExpiresAt)
|
|
if err != nil {
|
|
a.log.Error("could not encode refreshed session expiration date", zap.Error(ErrAuthAPI.Wrap(err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// GetUserSettings gets a user's settings.
|
|
func (a *Auth) GetUserSettings(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
settings, err := a.service.GetUserSettings(ctx)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
err = json.NewEncoder(w).Encode(settings)
|
|
if err != nil {
|
|
a.log.Error("could not encode settings", zap.Error(ErrAuthAPI.Wrap(err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// SetOnboardingStatus updates a user's onboarding status.
|
|
func (a *Auth) SetOnboardingStatus(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
var updateInfo struct {
|
|
OnboardingStart *bool `json:"onboardingStart"`
|
|
OnboardingEnd *bool `json:"onboardingEnd"`
|
|
OnboardingStep *string `json:"onboardingStep"`
|
|
}
|
|
|
|
err = json.NewDecoder(r.Body).Decode(&updateInfo)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
_, err = a.service.SetUserSettings(ctx, console.UpsertUserSettingsRequest{
|
|
OnboardingStart: updateInfo.OnboardingStart,
|
|
OnboardingEnd: updateInfo.OnboardingEnd,
|
|
OnboardingStep: updateInfo.OnboardingStep,
|
|
})
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// SetUserSettings updates a user's settings.
|
|
func (a *Auth) SetUserSettings(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
var err error
|
|
defer mon.Task()(&ctx)(&err)
|
|
|
|
var updateInfo struct {
|
|
OnboardingStart *bool `json:"onboardingStart"`
|
|
OnboardingEnd *bool `json:"onboardingEnd"`
|
|
PassphrasePrompt *bool `json:"passphrasePrompt"`
|
|
OnboardingStep *string `json:"onboardingStep"`
|
|
SessionDuration *int64 `json:"sessionDuration"`
|
|
}
|
|
|
|
err = json.NewDecoder(r.Body).Decode(&updateInfo)
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
var newDuration **time.Duration
|
|
if updateInfo.SessionDuration != nil {
|
|
newDuration = new(*time.Duration)
|
|
if *updateInfo.SessionDuration != 0 {
|
|
duration := time.Duration(*updateInfo.SessionDuration)
|
|
*newDuration = &duration
|
|
}
|
|
}
|
|
|
|
settings, err := a.service.SetUserSettings(ctx, console.UpsertUserSettingsRequest{
|
|
OnboardingStart: updateInfo.OnboardingStart,
|
|
OnboardingEnd: updateInfo.OnboardingEnd,
|
|
OnboardingStep: updateInfo.OnboardingStep,
|
|
PassphrasePrompt: updateInfo.PassphrasePrompt,
|
|
SessionDuration: newDuration,
|
|
})
|
|
if err != nil {
|
|
a.serveJSONError(w, err)
|
|
return
|
|
}
|
|
|
|
err = json.NewEncoder(w).Encode(settings)
|
|
if err != nil {
|
|
a.log.Error("could not encode settings", zap.Error(ErrAuthAPI.Wrap(err)))
|
|
return
|
|
}
|
|
}
|
|
|
|
// serveJSONError writes JSON error to response output stream.
|
|
func (a *Auth) serveJSONError(w http.ResponseWriter, err error) {
|
|
status := a.getStatusCode(err)
|
|
web.ServeCustomJSONError(a.log, w, status, err, a.getUserErrorMessage(err))
|
|
}
|
|
|
|
// getStatusCode returns http.StatusCode depends on console error class.
|
|
func (a *Auth) getStatusCode(err error) int {
|
|
var maxBytesError *http.MaxBytesError
|
|
|
|
switch {
|
|
case console.ErrValidation.Has(err), console.ErrCaptcha.Has(err), console.ErrMFAMissing.Has(err), console.ErrMFAPasscode.Has(err), console.ErrMFARecoveryCode.Has(err), console.ErrChangePassword.Has(err):
|
|
return http.StatusBadRequest
|
|
case console.ErrUnauthorized.Has(err), console.ErrTokenExpiration.Has(err), console.ErrRecoveryToken.Has(err), console.ErrLoginCredentials.Has(err):
|
|
return http.StatusUnauthorized
|
|
case console.ErrEmailUsed.Has(err), console.ErrMFAConflict.Has(err):
|
|
return http.StatusConflict
|
|
case errors.Is(err, errNotImplemented):
|
|
return http.StatusNotImplemented
|
|
case errors.As(err, &maxBytesError):
|
|
return http.StatusRequestEntityTooLarge
|
|
default:
|
|
return http.StatusInternalServerError
|
|
}
|
|
}
|
|
|
|
// getUserErrorMessage returns a user-friendly representation of the error.
|
|
func (a *Auth) getUserErrorMessage(err error) string {
|
|
var maxBytesError *http.MaxBytesError
|
|
|
|
switch {
|
|
case console.ErrCaptcha.Has(err):
|
|
return "Validation of captcha was unsuccessful"
|
|
case console.ErrRegToken.Has(err):
|
|
return "We are unable to create your account. This is an invite-only alpha, please join our waitlist to receive an invitation"
|
|
case console.ErrEmailUsed.Has(err):
|
|
return "This email is already in use; try another"
|
|
case console.ErrRecoveryToken.Has(err):
|
|
if console.ErrTokenExpiration.Has(err) {
|
|
return "The recovery token has expired"
|
|
}
|
|
return "The recovery token is invalid"
|
|
case console.ErrMFAMissing.Has(err):
|
|
return "A MFA passcode or recovery code is required"
|
|
case console.ErrMFAConflict.Has(err):
|
|
return "Expected either passcode or recovery code, but got both"
|
|
case console.ErrMFAPasscode.Has(err):
|
|
return "The MFA passcode is not valid or has expired"
|
|
case console.ErrMFARecoveryCode.Has(err):
|
|
return "The MFA recovery code is not valid or has been previously used"
|
|
case console.ErrLoginCredentials.Has(err):
|
|
return "Your login credentials are incorrect, please try again"
|
|
case console.ErrValidation.Has(err), console.ErrChangePassword.Has(err):
|
|
return err.Error()
|
|
case errors.Is(err, errNotImplemented):
|
|
return "The server is incapable of fulfilling the request"
|
|
case errors.As(err, &maxBytesError):
|
|
return "Request body is too large"
|
|
default:
|
|
return "There was an error processing your request"
|
|
}
|
|
}
|