Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: email verification #145

Merged
merged 12 commits into from
Dec 16, 2024
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 @@ -30,7 +30,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=${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();
}
}
37 changes: 25 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
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
18 changes: 15 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,21 @@ 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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
kosmoz marked this conversation as resolved.
Show resolved Hide resolved

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
6 changes: 6 additions & 0 deletions internal/handlers/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package handlers
import (
"errors"
"net/http"
"time"

"github.com/glasskube/cloud/internal/util"

"github.com/glasskube/cloud/api"
"github.com/glasskube/cloud/internal/apierrors"
Expand Down Expand Up @@ -34,6 +37,9 @@ func updateUserSettingsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
if body.EmailVerified && user.EmailVerifiedAt == nil {
user.EmailVerifiedAt = util.PtrTo(time.Now())
}
christophenne marked this conversation as resolved.
Show resolved Hide resolved

if err := db.UpateUserAccount(ctx, user); errors.Is(err, apierrors.ErrNotFound) {
http.Error(w, err.Error(), http.StatusBadRequest)
Expand Down
Loading
Loading