From 7d2716ef9d8f1bb284b2a38913e8b843d8e29f23 Mon Sep 17 00:00:00 2001 From: Jakob Steiner Date: Tue, 17 Dec 2024 17:06:06 +0100 Subject: [PATCH 1/2] feat: add password reset Signed-off-by: Jakob Steiner --- api/auth.go | 12 ++++ frontend/cloud-ui/src/app/app.routes.ts | 15 ++++- .../src/app/forgot/forgot.component.html | 57 +++++++++++++++++ .../src/app/forgot/forgot.component.ts | 60 +++++++++++++++++ .../src/app/login/login.component.html | 43 +++++++------ .../password-reset.component.html | 64 +++++++++++++++++++ .../password-reset.component.ts | 47 ++++++++++++++ .../cloud-ui/src/app/services/auth.service.ts | 7 +- internal/auth/auth.go | 16 +++++ internal/db/user_accounts.go | 31 ++++++--- internal/env/env.go | 8 +++ internal/handlers/auth.go | 33 ++++++++++ internal/mailtemplates/templates.go | 8 +++ .../templates/password-reset.html | 31 +++++++++ 14 files changed, 398 insertions(+), 34 deletions(-) create mode 100644 frontend/cloud-ui/src/app/forgot/forgot.component.html create mode 100644 frontend/cloud-ui/src/app/forgot/forgot.component.ts create mode 100644 frontend/cloud-ui/src/app/password-reset/password-reset.component.html create mode 100644 frontend/cloud-ui/src/app/password-reset/password-reset.component.ts create mode 100644 internal/mailtemplates/templates/password-reset.html diff --git a/api/auth.go b/api/auth.go index f6e81e30..f84eb8dd 100644 --- a/api/auth.go +++ b/api/auth.go @@ -26,3 +26,15 @@ func (r *AuthRegistrationRequest) Validate() error { return nil } } + +type AuthResetPasswordRequest struct { + Email string `json:"email"` +} + +func (r *AuthResetPasswordRequest) Validate() error { + if r.Email == "" { + return errors.New("email is empty") + } else { + return nil + } +} diff --git a/frontend/cloud-ui/src/app/app.routes.ts b/frontend/cloud-ui/src/app/app.routes.ts index 339a7804..ab78de5e 100644 --- a/frontend/cloud-ui/src/app/app.routes.ts +++ b/frontend/cloud-ui/src/app/app.routes.ts @@ -17,10 +17,13 @@ import {InviteComponent} from './invite/invite.component'; import {UsersComponent} from './components/users/users.component'; import {DeploymentsPageComponent} from './deployments/deployments-page.component'; import {VerifyComponent} from './verify/verify.component'; +import {ForgotComponent} from './forgot/forgot.component'; +import {PasswordResetComponent} from './password-reset/password-reset.component'; export const routes: Routes = [ {path: 'login', component: LoginComponent}, {path: 'register', component: RegisterComponent}, + {path: 'forgot', component: ForgotComponent}, { path: '', canActivate: [ @@ -41,14 +44,21 @@ export const routes: Routes = [ const auth = inject(AuthService); const router = inject(Router); if (auth.isAuthenticated) { - if (!auth.getClaims().email_verified) { + if (auth.getClaims().password_reset) { + if (state.url === '/reset') { + return true; + } else { + return router.createUrlTree(['/reset']); + } + } else if (!auth.getClaims().email_verified) { if (state.url === '/verify') { return true; } else { return router.createUrlTree(['/verify']); } + } else { + return true; } - return true; } else { return router.createUrlTree(['/login']); } @@ -57,6 +67,7 @@ export const routes: Routes = [ children: [ {path: '', pathMatch: 'full', redirectTo: 'dashboard'}, {path: 'verify', component: VerifyComponent}, + {path: 'reset', component: PasswordResetComponent}, {path: 'join', component: InviteComponent}, { path: '', diff --git a/frontend/cloud-ui/src/app/forgot/forgot.component.html b/frontend/cloud-ui/src/app/forgot/forgot.component.html new file mode 100644 index 00000000..a8f9de69 --- /dev/null +++ b/frontend/cloud-ui/src/app/forgot/forgot.component.html @@ -0,0 +1,57 @@ +
+
+ + Glasskube + Glasskube + +
+
+ @if (success) { +

+ Check your inbox… +

+ } @else { +

+ Request password reset +

+
+
+ + + @if (formGroup.controls.email.touched && formGroup.controls.email.errors) { +
Please enter a valid email address
+ } +
+ @if (errorMessage) { +
{{ errorMessage }}
+ } + +

+ Back to login +

+

+ Don’t have an account yet?
+ Sign up +

+
+ } +
+
+
+
diff --git a/frontend/cloud-ui/src/app/forgot/forgot.component.ts b/frontend/cloud-ui/src/app/forgot/forgot.component.ts new file mode 100644 index 00000000..09609e53 --- /dev/null +++ b/frontend/cloud-ui/src/app/forgot/forgot.component.ts @@ -0,0 +1,60 @@ +import {Component, inject, OnDestroy, OnInit} from '@angular/core'; +import {FormGroup, FormControl, Validators, ReactiveFormsModule} from '@angular/forms'; +import {Router, ActivatedRoute, RouterLink} from '@angular/router'; +import {distinctUntilChanged, filter, lastValueFrom, map, Subject, takeUntil} from 'rxjs'; +import {AuthService} from '../services/auth.service'; +import {HttpErrorResponse} from '@angular/common/http'; + +@Component({ + selector: 'app-forgot', + imports: [ReactiveFormsModule, RouterLink], + templateUrl: './forgot.component.html', +}) +export class ForgotComponent implements OnInit, OnDestroy { + public readonly formGroup = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + }); + public errorMessage?: string; + public success = false; + private readonly auth = inject(AuthService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly destroyed$ = new Subject(); + + public ngOnInit(): void { + this.route.queryParams + .pipe( + map((params) => params['email']), + filter((email) => email), + distinctUntilChanged(), + takeUntil(this.destroyed$) + ) + .subscribe((email) => { + this.formGroup.patchValue({email}); + }); + } + + public ngOnDestroy(): void { + this.destroyed$.next(); + } + + public async submit(): Promise { + this.formGroup.markAllAsTouched(); + this.errorMessage = undefined; + if (this.formGroup.valid) { + const value = this.formGroup.value; + try { + await lastValueFrom(this.auth.resetPassword(value.email!)); + } catch (e) { + if (e instanceof HttpErrorResponse && e.status < 500 && typeof e.error === 'string') { + this.errorMessage = e.error; + } else { + console.error(e); + this.errorMessage = 'something went wrong'; + } + return; + } + this.success = true; + } + } +} diff --git a/frontend/cloud-ui/src/app/login/login.component.html b/frontend/cloud-ui/src/app/login/login.component.html index de2ef890..45966423 100644 --- a/frontend/cloud-ui/src/app/login/login.component.html +++ b/frontend/cloud-ui/src/app/login/login.component.html @@ -37,30 +37,31 @@

- {{ errorMessage }} - - - } + --> + + Forgot password? + + + + + + diff --git a/frontend/cloud-ui/src/app/password-reset/password-reset.component.ts b/frontend/cloud-ui/src/app/password-reset/password-reset.component.ts new file mode 100644 index 00000000..5158b9da --- /dev/null +++ b/frontend/cloud-ui/src/app/password-reset/password-reset.component.ts @@ -0,0 +1,47 @@ +import {HttpErrorResponse} from '@angular/common/http'; +import {Component, inject} from '@angular/core'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {Router} from '@angular/router'; +import {lastValueFrom} from 'rxjs'; +import {AuthService} from '../services/auth.service'; +import {SettingsService} from '../services/settings.service'; + +@Component({ + selector: 'app-password-reset', + imports: [ReactiveFormsModule], + templateUrl: './password-reset.component.html', +}) +export class PasswordResetComponent { + private readonly settings = inject(SettingsService); + private readonly auth = inject(AuthService); + private readonly router = inject(Router); + + public readonly form = new FormGroup( + { + password: new FormControl('', [Validators.required, Validators.minLength(8)]), + passwordConfirm: new FormControl('', [Validators.required]), + }, + (control) => (control.value.password === control.value.passwordConfirm ? null : {passwordMismatch: 'error'}) + ); + public readonly email = this.auth.getClaims().email; + public errorMessage?: string; + + public async submit() { + this.form.markAllAsTouched(); + this.errorMessage = undefined; + if (this.form.valid) { + try { + await lastValueFrom(this.settings.updateUserSettings({password: this.form.value.password!})); + } catch (e) { + if (e instanceof HttpErrorResponse && e.status < 500 && typeof e.error === 'string') { + this.errorMessage = e.error; + } else { + this.errorMessage = 'something went wrong'; + } + console.error(e); + } + await lastValueFrom(this.auth.logout()); + location.assign(`/login?email=${encodeURIComponent(this.email)}`); + } + } +} diff --git a/frontend/cloud-ui/src/app/services/auth.service.ts b/frontend/cloud-ui/src/app/services/auth.service.ts index 3c31dab4..720697ff 100644 --- a/frontend/cloud-ui/src/app/services/auth.service.ts +++ b/frontend/cloud-ui/src/app/services/auth.service.ts @@ -12,6 +12,7 @@ export interface JWTClaims { sub: string; org: string; email: string; + password_reset: boolean; email_verified: boolean; name: string; exp: string; @@ -51,6 +52,10 @@ export class AuthService { ); } + public resetPassword(email: string): Observable { + return this.httpClient.post(`${this.baseUrl}/reset`, {email}); + } + public register(email: string, name: string | null | undefined, password: string): Observable { let body: any = {email, password}; if (name) { @@ -75,7 +80,7 @@ export class AuthService { export const tokenInterceptor: HttpInterceptorFn = (req, next) => { const auth = inject(AuthService); - if (req.url !== '/api/v1/auth/login' && req.url !== '/api/v1/auth/register') { + if (!req.url.startsWith('/api/v1/auth/')) { const token = auth.token; try { if (dayjs.unix(parseInt(auth.getClaims().exp)).isAfter(dayjs())) { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index d7392540..545c460a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -23,6 +23,7 @@ const ( UserEmailVerifiedKey = "email_verified" UserRoleKey = "role" OrgIdKey = "org" + PasswordResetKey = "password_reset" ) // JWTAuth is for generating/validating JWTs. @@ -56,6 +57,21 @@ func GenerateTokenValidFor( return JWTAuth.Encode(claims) } +func GenerateResetToken(user types.UserAccount) (jwt.Token, string, error) { + now := time.Now() + claims := map[string]any{ + jwt.IssuedAtKey: now, + jwt.NotBeforeKey: now, + jwt.ExpirationKey: now.Add(env.ResetTokenValidDuration()), + jwt.SubjectKey: user.ID, + UserNameKey: user.Name, + UserEmailKey: user.Email, + UserEmailVerifiedKey: user.EmailVerifiedAt != nil, + PasswordResetKey: true, + } + return JWTAuth.Encode(claims) +} + func GenerateVerificationTokenValidFor( user types.UserAccount, org types.OrganizationWithUserRole, diff --git a/internal/db/user_accounts.go b/internal/db/user_accounts.go index 54691e8e..055a5bd8 100644 --- a/internal/db/user_accounts.go +++ b/internal/db/user_accounts.go @@ -186,18 +186,29 @@ func GetUserAccountWithEmail(ctx context.Context, email string) (*types.UserAcco // // TODO: this function should probably be moved to another module and maybe support some kind of result caching. func GetCurrentUser(ctx context.Context) (*types.UserAccount, error) { - if user, err := GetCurrentUserWithRole(ctx); err != nil { + if userId, err := auth.CurrentUserId(ctx); err != nil { return nil, err } else { - return &types.UserAccount{ - ID: user.ID, - CreatedAt: user.CreatedAt, - Email: user.Email, - PasswordHash: user.PasswordHash, - PasswordSalt: user.PasswordSalt, - Name: user.Name, - Password: user.Password, - }, nil + db := internalctx.GetDb(ctx) + rows, err := db.Query(ctx, + "SELECT "+userAccountOutputExpr+` + FROM UserAccount u + WHERE u.id = @id`, + pgx.NamedArgs{"id": userId}, + ) + if err != nil { + return nil, err + } + userAccount, err := pgx.CollectExactlyOneRow[types.UserAccount](rows, pgx.RowToStructByName) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, apierrors.ErrNotFound + } else { + return nil, fmt.Errorf("could not map user: %w", err) + } + } else { + return &userAccount, nil + } } } diff --git a/internal/env/env.go b/internal/env/env.go index 927fff78..23cdc7cc 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -23,6 +23,7 @@ var ( host string mailerConfig MailerConfig inviteTokenValidDuration = 24 * time.Hour + resetTokenValidDuration = 1 * time.Hour agentTokenMaxValidDuration = 24 * time.Hour agentInterval = 5 * time.Second ) @@ -72,6 +73,9 @@ func init() { if d, ok := os.LookupEnv("INVITE_TOKEN_VALID_DURATION"); ok { inviteTokenValidDuration = util.Require(time.ParseDuration(d)) } + if d, ok := os.LookupEnv("RESET_TOKEN_VALID_DURATION"); ok { + resetTokenValidDuration = util.Require(time.ParseDuration(d)) + } if d, ok := os.LookupEnv("AGENT_TOKEN_MAX_VALID_DURATION"); ok { agentTokenMaxValidDuration = util.Require(time.ParseDuration(d)) } @@ -102,6 +106,10 @@ func InviteTokenValidDuration() time.Duration { return inviteTokenValidDuration } +func ResetTokenValidDuration() time.Duration { + return resetTokenValidDuration +} + func AgentTokenMaxValidDuration() time.Duration { return agentTokenMaxValidDuration } diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 8952c40b..0e0a397a 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -24,6 +24,7 @@ import ( func AuthRouter(r chi.Router) { r.Post("/login", authLoginHandler) r.Post("/register", authRegisterHandler) + r.Post("/reset", authResetPasswordHandler) } func authLoginHandler(w http.ResponseWriter, r *http.Request) { @@ -115,3 +116,35 @@ func authRegisterHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } } + +func authResetPasswordHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := internalctx.GetLogger(ctx) + mailer := internalctx.GetMailer(ctx) + if request, err := JsonBody[api.AuthResetPasswordRequest](w, r); err != nil { + return + } else if err := request.Validate(); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } else if user, err := db.GetUserAccountWithEmail(ctx, request.Email); err != nil { + if errors.Is(err, apierrors.ErrNotFound) { + log.Info("password reset for non-existing user", zap.String("email", request.Email)) + w.WriteHeader(http.StatusNoContent) + } else { + log.Warn("could not send reset mail", zap.Error(err)) + http.Error(w, "something went wrong", http.StatusInternalServerError) + } + } else if _, token, err := auth.GenerateResetToken(*user); err != nil { + log.Warn("could not send reset mail", zap.Error(err)) + http.Error(w, "something went wrong", http.StatusInternalServerError) + } else if err := mailer.Send(ctx, mail.New( + mail.To(user.Email), + mail.Subject("Password reset"), + mail.HtmlBodyTemplate(mailtemplates.PasswordReset(*user, token)), + )); err != nil { + log.Warn("could not send reset mail", zap.Error(err)) + http.Error(w, "something went wrong", http.StatusInternalServerError) + } else { + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/internal/mailtemplates/templates.go b/internal/mailtemplates/templates.go index 7ea8e144..ac4c360b 100644 --- a/internal/mailtemplates/templates.go +++ b/internal/mailtemplates/templates.go @@ -91,3 +91,11 @@ func VerifyEmail(userAccount types.UserAccount, token string) (*template.Templat "Token": token, } } + +func PasswordReset(userAccount types.UserAccount, token string) (*template.Template, any) { + return templates.Lookup("password-reset.html"), map[string]any{ + "UserAccount": userAccount, + "Host": env.Host(), + "Token": token, + } +} diff --git a/internal/mailtemplates/templates/password-reset.html b/internal/mailtemplates/templates/password-reset.html new file mode 100644 index 00000000..92ae13e7 --- /dev/null +++ b/internal/mailtemplates/templates/password-reset.html @@ -0,0 +1,31 @@ + + + + + {{ template "fragments/style.html" }} + + +
+ {{ template "fragments/header.html" }} +
+ {{if .UserAccount.Name}} +

Hi {{.UserAccount.Name}}

+ {{else}} +

Hi,

+ {{end}} + +

+ A password reset was requested for this email address on Glasskube Cloud. If this was you, please + click here to complete the reset. +

+ +
+ {{.Host}}/reset?jwt={{.Token | QueryEscape}} +
+ +

{{template "fragments/signature.html"}}

+
+ {{template "fragments/footer.html"}} +
+ + From cacb8a184a1e336295a455b727781ae0d1da4be9 Mon Sep 17 00:00:00 2001 From: Jakob Steiner Date: Wed, 18 Dec 2024 12:26:24 +0100 Subject: [PATCH 2/2] chore: add sentry reporting for internal errors --- internal/handlers/auth.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index d366cf18..6110753e 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -137,10 +137,12 @@ func authResetPasswordHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } else { log.Warn("could not send reset mail", zap.Error(err)) + sentry.GetHubFromContext(ctx).CaptureException(err) http.Error(w, "something went wrong", http.StatusInternalServerError) } } else if _, token, err := auth.GenerateResetToken(*user); err != nil { log.Warn("could not send reset mail", zap.Error(err)) + sentry.GetHubFromContext(ctx).CaptureException(err) http.Error(w, "something went wrong", http.StatusInternalServerError) } else if err := mailer.Send(ctx, mail.New( mail.To(user.Email), @@ -148,6 +150,7 @@ func authResetPasswordHandler(w http.ResponseWriter, r *http.Request) { mail.HtmlBodyTemplate(mailtemplates.PasswordReset(*user, token)), )); err != nil { log.Warn("could not send reset mail", zap.Error(err)) + sentry.GetHubFromContext(ctx).CaptureException(err) http.Error(w, "something went wrong", http.StatusInternalServerError) } else { w.WriteHeader(http.StatusNoContent)