Skip to content

Commit

Permalink
feat: email verification (#145)
Browse files Browse the repository at this point in the history
Signed-off-by: christophenne <christoph.enne@glasskube.eu>
  • Loading branch information
christophenne authored Dec 16, 2024
1 parent cf17cb3 commit e78be22
Show file tree
Hide file tree
Showing 18 changed files with 236 additions and 47 deletions.
5 changes: 3 additions & 2 deletions api/user_accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
15 changes: 13 additions & 2 deletions cmd/cloud/generate/demo/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"time"

internalctx "github.com/glasskube/cloud/internal/context"
"github.com/glasskube/cloud/internal/db"
Expand All @@ -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))
Expand Down
15 changes: 13 additions & 2 deletions cmd/cloud/generate/dummy/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"time"

internalctx "github.com/glasskube/cloud/internal/context"
"github.com/glasskube/cloud/internal/db"
Expand All @@ -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))
Expand Down
20 changes: 18 additions & 2 deletions frontend/cloud-ui/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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},
Expand All @@ -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']);
Expand All @@ -41,6 +56,7 @@ export const routes: Routes = [
],
children: [
{path: '', pathMatch: 'full', redirectTo: 'dashboard'},
{path: 'verify', component: VerifyComponent},
{path: 'join', component: InviteComponent},
{
path: '',
Expand Down
4 changes: 3 additions & 1 deletion frontend/cloud-ui/src/app/invite/invite.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
}
Expand Down
1 change: 1 addition & 0 deletions frontend/cloud-ui/src/app/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface JWTClaims {
sub: string;
org: string;
email: string;
email_verified: boolean;
name: string;
exp: string;
role: UserRole;
Expand Down
6 changes: 5 additions & 1 deletion frontend/cloud-ui/src/app/services/settings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserAccount> {
public updateUserSettings(request: {
name?: string;
password?: string;
emailVerified?: boolean;
}): Observable<UserAccount> {
return this.httpClient.post<UserAccount>(`${this.baseUrl}/user`, request);
}
}
20 changes: 20 additions & 0 deletions frontend/cloud-ui/src/app/verify/verify.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<section class="bg-gray-50 dark:bg-gray-900">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<a href="#" class="flex items-center mb-6 text-2xl font-semibold text-gray-900 dark:text-white">
<img class="w-8 h-8 mr-2" src="glasskube-logo.svg" alt="Glasskube" />
Glasskube
</a>
<div
class="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
<div class="p-6 space-y-4 md:space-y-6 sm:p-8">
<h1
class="text-xl text-center font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
Thanks for signing up!
</h1>
<p class="text-base font-normal text-gray-500 dark:text-gray-400">
We've sent you an email. Please click the link inside of it to confirm your registration.
</p>
</div>
</div>
</div>
</section>
36 changes: 36 additions & 0 deletions frontend/cloud-ui/src/app/verify/verify.component.ts
Original file line number Diff line number Diff line change
@@ -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<void>();

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();
}
}
47 changes: 35 additions & 12 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
29 changes: 16 additions & 13 deletions internal/db/user_accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
17 changes: 14 additions & 3 deletions internal/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
Expand Down
Loading

0 comments on commit e78be22

Please sign in to comment.