diff --git a/api/user_accounts.go b/api/user_accounts.go index 740c0845..becf3e3b 100644 --- a/api/user_accounts.go +++ b/api/user_accounts.go @@ -10,6 +10,7 @@ type CreateUserAccountRequest struct { } type UpdateUserAccountRequest struct { - Name string `json:"name"` - Password string `json:"password"` + Name string `json:"name"` + Password string `json:"password"` + EmailVerified bool `json:"emailVerified"` } diff --git a/cmd/cloud/generate/demo/generate.go b/cmd/cloud/generate/demo/generate.go index bcd93d3a..178d372b 100644 --- a/cmd/cloud/generate/demo/generate.go +++ b/cmd/cloud/generate/demo/generate.go @@ -2,6 +2,7 @@ package main import ( "context" + "time" internalctx "github.com/glasskube/cloud/internal/context" "github.com/glasskube/cloud/internal/db" @@ -20,12 +21,22 @@ func main() { org := types.Organization{Name: "Glasskube"} util.Must(db.CreateOrganization(ctx, &org)) - pmig := types.UserAccount{Email: "pmig@glasskube.com", Name: "Philip Miglinci", Password: "12345678"} + pmig := types.UserAccount{ + Email: "pmig@glasskube.com", + Name: "Philip Miglinci", + Password: "12345678", + EmailVerifiedAt: util.PtrTo(time.Now()), + } util.Must(security.HashPassword(&pmig)) util.Must(db.CreateUserAccount(ctx, &pmig)) util.Must(db.CreateUserAccountOrganizationAssignment(ctx, pmig.ID, org.ID, types.UserRoleVendor)) - pmigCustomer := types.UserAccount{Email: "pmig+customer@glasskube.com", Name: "Philip Miglinci", Password: "12345678"} + pmigCustomer := types.UserAccount{ + Email: "pmig+customer@glasskube.com", + Name: "Philip Miglinci", + Password: "12345678", + EmailVerifiedAt: util.PtrTo(time.Now()), + } util.Must(security.HashPassword(&pmigCustomer)) util.Must(db.CreateUserAccount(ctx, &pmigCustomer)) util.Must(db.CreateUserAccountOrganizationAssignment(ctx, pmigCustomer.ID, org.ID, types.UserRoleCustomer)) diff --git a/cmd/cloud/generate/dummy/generate.go b/cmd/cloud/generate/dummy/generate.go index 4dfcf133..efe68764 100644 --- a/cmd/cloud/generate/dummy/generate.go +++ b/cmd/cloud/generate/dummy/generate.go @@ -2,6 +2,7 @@ package main import ( "context" + "time" internalctx "github.com/glasskube/cloud/internal/context" "github.com/glasskube/cloud/internal/db" @@ -20,12 +21,22 @@ func main() { org := types.Organization{Name: "Glasskube"} util.Must(db.CreateOrganization(ctx, &org)) - pmig := types.UserAccount{Email: "pmig@glasskube.com", Name: "Philip Miglinci", Password: "12345678"} + pmig := types.UserAccount{ + Email: "pmig@glasskube.com", + Name: "Philip Miglinci", + Password: "12345678", + EmailVerifiedAt: util.PtrTo(time.Now()), + } util.Must(security.HashPassword(&pmig)) util.Must(db.CreateUserAccount(ctx, &pmig)) util.Must(db.CreateUserAccountOrganizationAssignment(ctx, pmig.ID, org.ID, types.UserRoleVendor)) - kosmoz := types.UserAccount{Email: "jakob.steiner@glasskube.eu", Name: "Jakob Steiner", Password: "asdasdasd"} + kosmoz := types.UserAccount{ + Email: "jakob.steiner@glasskube.eu", + Name: "Jakob Steiner", + Password: "asdasdasd", + EmailVerifiedAt: util.PtrTo(time.Now()), + } util.Must(security.HashPassword(&kosmoz)) util.Must(db.CreateUserAccount(ctx, &kosmoz)) util.Must(db.CreateUserAccountOrganizationAssignment(ctx, kosmoz.ID, org.ID, types.UserRoleCustomer)) diff --git a/frontend/cloud-ui/src/app/app.routes.ts b/frontend/cloud-ui/src/app/app.routes.ts index 6b00d2bd..339a7804 100644 --- a/frontend/cloud-ui/src/app/app.routes.ts +++ b/frontend/cloud-ui/src/app/app.routes.ts @@ -1,4 +1,11 @@ -import {ActivatedRouteSnapshot, createUrlTreeFromSnapshot, Router, Routes, UrlTree} from '@angular/router'; +import { + ActivatedRouteSnapshot, + createUrlTreeFromSnapshot, + Router, + RouterStateSnapshot, + Routes, + UrlTree, +} from '@angular/router'; import {DashboardPlaceholderComponent} from './components/dashboard-placeholder/dashboard-placeholder.component'; import {ApplicationsPageComponent} from './applications/applications-page.component'; import {inject} from '@angular/core'; @@ -9,6 +16,7 @@ import {RegisterComponent} from './register/register.component'; 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'; export const routes: Routes = [ {path: 'login', component: LoginComponent}, @@ -29,10 +37,17 @@ export const routes: Routes = [ return newtree; } }, - () => { + (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => { const auth = inject(AuthService); const router = inject(Router); if (auth.isAuthenticated) { + if (!auth.getClaims().email_verified) { + if (state.url === '/verify') { + return true; + } else { + return router.createUrlTree(['/verify']); + } + } return true; } else { return router.createUrlTree(['/login']); @@ -41,6 +56,7 @@ export const routes: Routes = [ ], children: [ {path: '', pathMatch: 'full', redirectTo: 'dashboard'}, + {path: 'verify', component: VerifyComponent}, {path: 'join', component: InviteComponent}, { path: '', diff --git a/frontend/cloud-ui/src/app/invite/invite.component.ts b/frontend/cloud-ui/src/app/invite/invite.component.ts index e915213b..23c8f91e 100644 --- a/frontend/cloud-ui/src/app/invite/invite.component.ts +++ b/frontend/cloud-ui/src/app/invite/invite.component.ts @@ -29,7 +29,9 @@ export class InviteComponent { if (this.form.valid) { this.submitted = true; const value = this.form.value; - await firstValueFrom(this.settings.updateUserSettings({name: value.name, password: value.password})); + await firstValueFrom( + this.settings.updateUserSettings({name: value.name, password: value.password, emailVerified: true}) + ); 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 8d06c778..3c31dab4 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; + email_verified: boolean; name: string; exp: string; role: UserRole; diff --git a/frontend/cloud-ui/src/app/services/settings.service.ts b/frontend/cloud-ui/src/app/services/settings.service.ts index 01bebf75..69a4941d 100644 --- a/frontend/cloud-ui/src/app/services/settings.service.ts +++ b/frontend/cloud-ui/src/app/services/settings.service.ts @@ -8,7 +8,11 @@ export class SettingsService { private readonly httpClient = inject(HttpClient); private readonly baseUrl = '/api/v1/settings'; - public updateUserSettings(request: {name?: string; password?: string}): Observable { + public updateUserSettings(request: { + name?: string; + password?: string; + emailVerified?: boolean; + }): Observable { return this.httpClient.post(`${this.baseUrl}/user`, request); } } diff --git a/frontend/cloud-ui/src/app/verify/verify.component.html b/frontend/cloud-ui/src/app/verify/verify.component.html new file mode 100644 index 00000000..bdf45f31 --- /dev/null +++ b/frontend/cloud-ui/src/app/verify/verify.component.html @@ -0,0 +1,20 @@ +
+
+ + Glasskube + Glasskube + +
+
+

+ Thanks for signing up! +

+

+ We've sent you an email. Please click the link inside of it to confirm your registration. +

+
+
+
+
diff --git a/frontend/cloud-ui/src/app/verify/verify.component.ts b/frontend/cloud-ui/src/app/verify/verify.component.ts new file mode 100644 index 00000000..981d29f7 --- /dev/null +++ b/frontend/cloud-ui/src/app/verify/verify.component.ts @@ -0,0 +1,36 @@ +import {Component, inject, OnDestroy, OnInit} from '@angular/core'; +import {AuthService} from '../services/auth.service'; +import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; +import {distinctUntilChanged, filter, firstValueFrom, lastValueFrom, map, Subject, takeUntil} from 'rxjs'; +import {ActivatedRoute, Router, RouterLink} from '@angular/router'; +import {SettingsService} from '../services/settings.service'; +import {ToastService} from '../services/toast.service'; + +@Component({ + selector: 'app-verify', + imports: [ReactiveFormsModule], + templateUrl: './verify.component.html', +}) +export class VerifyComponent implements OnInit, OnDestroy { + private readonly auth = inject(AuthService); + private readonly settings = inject(SettingsService); + private readonly toast = inject(ToastService); + public readonly formGroup = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + password: new FormControl('', [Validators.required]), + }); + private readonly router = inject(Router); + private readonly destroyed$ = new Subject(); + + async ngOnInit() { + if (this.auth.getClaims().email_verified) { + await firstValueFrom(this.settings.updateUserSettings({emailVerified: true})); + this.toast.success('Your email has been verified'); + await this.router.navigate(['/']); + } + } + + public ngOnDestroy(): void { + this.destroyed$.next(); + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e2285495..7320ffab 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -5,6 +5,8 @@ import ( "errors" "time" + "github.com/glasskube/cloud/internal/util" + "github.com/glasskube/cloud/internal/env" "github.com/glasskube/cloud/internal/types" "github.com/go-chi/jwtauth/v5" @@ -16,10 +18,11 @@ const ( ) const ( - UserNameKey = "name" - UserEmailKey = "email" - UserRoleKey = "role" - OrgIdKey = "org" + UserNameKey = "name" + UserEmailKey = "email" + UserEmailVerifiedKey = "email_verified" + UserRoleKey = "role" + OrgIdKey = "org" ) // JWTAuth is for generating/validating JWTs. @@ -40,18 +43,28 @@ func GenerateTokenValidFor( ) (jwt.Token, string, error) { now := time.Now() claims := map[string]any{ - jwt.IssuedAtKey: now, - jwt.NotBeforeKey: now, - jwt.ExpirationKey: now.Add(validFor), - jwt.SubjectKey: user.ID, - UserNameKey: user.Name, - UserEmailKey: user.Email, - UserRoleKey: org.UserRole, - OrgIdKey: org.ID, + jwt.IssuedAtKey: now, + jwt.NotBeforeKey: now, + jwt.ExpirationKey: now.Add(validFor), + jwt.SubjectKey: user.ID, + UserNameKey: user.Name, + UserEmailKey: user.Email, + UserEmailVerifiedKey: user.EmailVerifiedAt != nil, + UserRoleKey: org.UserRole, + OrgIdKey: org.ID, } return JWTAuth.Encode(claims) } +func GenerateVerificationTokenValidFor( + user types.UserAccount, + org types.OrganizationWithUserRole, + validFor time.Duration, +) (jwt.Token, string, error) { + user.EmailVerifiedAt = util.PtrTo(time.Now()) + return GenerateTokenValidFor(user, org, validFor) +} + func CurrentUserId(ctx context.Context) (string, error) { if token, _, err := jwtauth.FromContext(ctx); err != nil { return "", err @@ -79,3 +92,13 @@ func CurrentOrgId(ctx context.Context) (string, error) { return orgId.(string), nil } } + +func CurrentUserEmailVerified(ctx context.Context) (bool, error) { + if token, _, err := jwtauth.FromContext(ctx); err != nil { + return false, err + } else if verified, ok := token.Get(UserEmailVerifiedKey); !ok { + return false, nil + } else { + return verified.(bool), nil + } +} diff --git a/internal/db/user_accounts.go b/internal/db/user_accounts.go index 459f6ad9..e27c4cdb 100644 --- a/internal/db/user_accounts.go +++ b/internal/db/user_accounts.go @@ -15,7 +15,7 @@ import ( ) const ( - userAccountOutputExpr = "u.id, u.created_at, u.email, u.password_hash, u.password_salt, u.name" + userAccountOutputExpr = "u.id, u.created_at, u.email, u.email_verified_at, u.password_hash, u.password_salt, u.name" ) func CreateUserAccountWithOrganization( @@ -44,14 +44,15 @@ func CreateUserAccountWithOrganization( func CreateUserAccount(ctx context.Context, userAccount *types.UserAccount) error { db := internalctx.GetDb(ctx) rows, err := db.Query(ctx, - "INSERT INTO UserAccount AS u (email, password_hash, password_salt, name) "+ - "VALUES (@email, @password_hash, @password_salt, @name) "+ + "INSERT INTO UserAccount AS u (email, password_hash, password_salt, name, email_verified_at) "+ + "VALUES (@email, @password_hash, @password_salt, @name, @email_verified_at) "+ "RETURNING "+userAccountOutputExpr, pgx.NamedArgs{ - "email": userAccount.Email, - "password_hash": userAccount.PasswordHash, - "password_salt": userAccount.PasswordSalt, - "name": userAccount.Name, + "email": userAccount.Email, + "password_hash": userAccount.PasswordHash, + "password_salt": userAccount.PasswordSalt, + "name": userAccount.Name, + "email_verified_at": userAccount.EmailVerifiedAt, }, ) if err != nil { @@ -74,15 +75,17 @@ func UpateUserAccount(ctx context.Context, userAccount *types.UserAccount) error SET email = @email, name = @name, password_hash = @password_hash, - password_salt = @password_salt + password_salt = @password_salt, + email_verified_at = @email_verified_at WHERE id = @id RETURNING `+userAccountOutputExpr, pgx.NamedArgs{ - "id": userAccount.ID, - "email": userAccount.Email, - "password_hash": userAccount.PasswordHash, - "password_salt": userAccount.PasswordSalt, - "name": userAccount.Name, + "id": userAccount.ID, + "email": userAccount.Email, + "password_hash": userAccount.PasswordHash, + "password_salt": userAccount.PasswordSalt, + "name": userAccount.Name, + "email_verified_at": userAccount.EmailVerifiedAt, }, ) if err != nil { diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index c6db12f9..928deea5 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -12,6 +12,7 @@ import ( "github.com/glasskube/cloud/internal/auth" internalctx "github.com/glasskube/cloud/internal/context" "github.com/glasskube/cloud/internal/db" + "github.com/glasskube/cloud/internal/env" "github.com/glasskube/cloud/internal/mail" "github.com/glasskube/cloud/internal/mailtemplates" "github.com/glasskube/cloud/internal/security" @@ -72,12 +73,13 @@ func authRegisterHandler(w http.ResponseWriter, r *http.Request) { Email: request.Email, Password: request.Password, } + var org *types.Organization if err := db.RunTx(ctx, pgx.TxOptions{}, func(ctx context.Context) error { if err := security.HashPassword(&userAccount); err != nil { w.WriteHeader(http.StatusInternalServerError) return err - } else if _, err := db.CreateUserAccountWithOrganization(ctx, &userAccount); err != nil { + } else if org, err = db.CreateUserAccountWithOrganization(ctx, &userAccount); err != nil { if errors.Is(err, apierrors.ErrAlreadyExists) { w.WriteHeader(http.StatusBadRequest) } else { @@ -91,11 +93,20 @@ func authRegisterHandler(w http.ResponseWriter, r *http.Request) { return } + _, token, err := auth.GenerateVerificationTokenValidFor( + userAccount, + types.OrganizationWithUserRole{Organization: *org, UserRole: types.UserRoleVendor}, + env.InviteTokenValidDuration(), + ) + if err != nil { + log.Error("could not generate verification token for welcome mail", zap.Error(err)) + } + mailer := internalctx.GetMailer(ctx) mail := mail.New( mail.To(userAccount.Email), - mail.Subject("Registration"), - mail.HtmlBodyTemplate(mailtemplates.Welcome()), + mail.Subject("Verify your Glasskube Cloud Email"), + mail.HtmlBodyTemplate(mailtemplates.VerifyEmail(userAccount, token)), ) if err := mailer.Send(ctx, mail); err != nil { log.Error("could not send welcome mail", zap.Error(err)) diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index a989f596..74ca0089 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -3,6 +3,13 @@ package handlers import ( "errors" "net/http" + "time" + + "github.com/glasskube/cloud/internal/auth" + internalctx "github.com/glasskube/cloud/internal/context" + "go.uber.org/zap" + + "github.com/glasskube/cloud/internal/util" "github.com/glasskube/cloud/api" "github.com/glasskube/cloud/internal/apierrors" @@ -17,6 +24,7 @@ func SettingsRouter(r chi.Router) { func updateUserSettingsHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + log := internalctx.GetLogger(ctx) body, err := JsonBody[api.UpdateUserAccountRequest](w, r) if err != nil { return @@ -34,6 +42,13 @@ func updateUserSettingsHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) } } + if verifiedInToken, err := auth.CurrentUserEmailVerified(ctx); err != nil { + log.Warn("failed to check whether current user verified in token", zap.Error(err)) + } else if verifiedInToken { + if body.EmailVerified && user.EmailVerifiedAt == nil { + user.EmailVerifiedAt = util.PtrTo(time.Now()) + } + } if err := db.UpateUserAccount(ctx, user); errors.Is(err, apierrors.ErrNotFound) { http.Error(w, err.Error(), http.StatusBadRequest) diff --git a/internal/handlers/user_accounts.go b/internal/handlers/user_accounts.go index a7d43f8c..75bcaf35 100644 --- a/internal/handlers/user_accounts.go +++ b/internal/handlers/user_accounts.go @@ -91,7 +91,7 @@ func createUserAccountHandler(w http.ResponseWriter, r *http.Request) { } // TODO: Should probably use a different mechanism for invite tokens but for now this should work OK - _, token, err := auth.GenerateTokenValidFor( + _, token, err := auth.GenerateVerificationTokenValidFor( userAccount, types.OrganizationWithUserRole{Organization: organization, UserRole: body.UserRole}, env.InviteTokenValidDuration(), diff --git a/internal/mailtemplates/templates.go b/internal/mailtemplates/templates.go index bcc58e77..7ea8e144 100644 --- a/internal/mailtemplates/templates.go +++ b/internal/mailtemplates/templates.go @@ -7,8 +7,9 @@ import ( "net/url" "path" - "github.com/glasskube/cloud/internal/env" "github.com/glasskube/cloud/internal/types" + + "github.com/glasskube/cloud/internal/env" ) //go:embed templates/* @@ -82,3 +83,11 @@ func InviteCustomer( "Token": token, } } + +func VerifyEmail(userAccount types.UserAccount, token string) (*template.Template, any) { + return templates.Lookup("verify-email-registration.html"), map[string]any{ + "UserAccount": userAccount, + "Host": env.Host(), + "Token": token, + } +} diff --git a/internal/mailtemplates/templates/verify-email-registration.html b/internal/mailtemplates/templates/verify-email-registration.html new file mode 100644 index 00000000..b9f0a632 --- /dev/null +++ b/internal/mailtemplates/templates/verify-email-registration.html @@ -0,0 +1,24 @@ + + + + + {{ template "fragments/style.html" }} + + +
+ {{ template "fragments/header.html" }} +
+ {{if .UserAccount.Name}} +

Hi {{.UserAccount.Name}}

+ {{else}} +

Hi,

+ {{end}} Welcome to glasskube.cloud! Please + click this link to verify that this is your email + address. + +

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

+
+ {{template "fragments/footer.html"}} +
+ + diff --git a/internal/types/data.go b/internal/types/data.go index a5c7e7ac..3b327d59 100644 --- a/internal/types/data.go +++ b/internal/types/data.go @@ -58,13 +58,14 @@ type DeploymentTargetStatus struct { } type UserAccount struct { - ID string `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"createdAt"` - Email string `db:"email" json:"email"` - PasswordHash []byte `db:"password_hash" json:"-"` - PasswordSalt []byte `db:"password_salt" json:"-"` - Name string `db:"name" json:"name,omitempty"` - Password string `db:"-" json:"-"` + ID string `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"createdAt"` + Email string `db:"email" json:"email"` + EmailVerifiedAt *time.Time `db:"email_verified_at" json:"-"` + PasswordHash []byte `db:"password_hash" json:"-"` + PasswordSalt []byte `db:"password_salt" json:"-"` + Name string `db:"name" json:"name,omitempty"` + Password string `db:"-" json:"-"` } type UserAccountWithUserRole struct { diff --git a/sql/init_db.sql b/sql/init_db.sql index 3cfec08a..0fbabe54 100644 --- a/sql/init_db.sql +++ b/sql/init_db.sql @@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS UserAccount ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), created_at TIMESTAMP DEFAULT current_timestamp, email TEXT NOT NULL UNIQUE, + email_verified_at TIMESTAMP, password_hash BYTEA, password_salt BYTEA, name TEXT