From c31f30ba5cd4e8c7ac35088a3e08fb79d3115de7 Mon Sep 17 00:00:00 2001 From: mikestefanello Date: Sat, 8 Jan 2022 15:32:18 -0500 Subject: [PATCH] Added user email verification support. --- README.md | 45 +++++++++++++------- config/config.go | 6 ++- ent/ent.go | 5 +-- ent/enttest/enttest.go | 1 - ent/migrate/schema.go | 1 + ent/mutation.go | 56 ++++++++++++++++++++++++- ent/passwordtoken.go | 3 +- ent/passwordtoken/where.go | 3 +- ent/passwordtoken_create.go | 5 +-- ent/passwordtoken_delete.go | 5 +-- ent/passwordtoken_query.go | 7 ++-- ent/passwordtoken_update.go | 7 ++-- ent/runtime.go | 2 +- ent/runtime/runtime.go | 6 ++- ent/schema/user.go | 2 + ent/user.go | 15 ++++++- ent/user/user.go | 7 +++- ent/user/where.go | 24 ++++++++++- ent/user_create.go | 34 ++++++++++++++-- ent/user_delete.go | 5 +-- ent/user_query.go | 7 ++-- ent/user_update.go | 49 ++++++++++++++++++++-- go.mod | 2 +- go.sum | 7 ---- routes/register.go | 29 ++++++++++++- routes/router.go | 3 ++ routes/verify_email.go | 70 ++++++++++++++++++++++++++++++++ services/auth.go | 36 ++++++++++++++++ services/auth_test.go | 42 ++++++++++++++++--- templates/components/core.gohtml | 2 +- templates/layouts/main.gohtml | 16 ++++---- 31 files changed, 417 insertions(+), 85 deletions(-) create mode 100644 routes/verify_email.go diff --git a/README.md b/README.md index 24c45ed..e28fe7f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ * [Registration](#registration) * [Authenticated user](#authenticated-user) * [Middleware](#middleware) + * [Email verification](#email-verification) * [Routes](#routes) * [Custom middleware](#custom-middleware) * [Controller / Dependencies](#controller--dependencies) @@ -342,6 +343,20 @@ Registered for all routes is middleware that will load the currently logged in u If you wish to require either authentication or non-authentication for a given route, you can use either `middleware.RequireAuthentication()` or `middleware.RequireNoAuthentication()`. +### Email verification + +Most web applications require the user to verify their email address (or other form of contact information). The `User` entity has a field `Verified` to indicate if they have verified themself. When a user successfully registers, an email is sent to them containing a link with a token that will verify their account when visited. This route is currently accessible at `/email/verify/:token` and handled by `routes/VerifyEmail`. + +There is currently no enforcement that a `User` must be verified in order to access the application. If that is something you desire, it will have to be added in yourself. It was not included because you may want partial access of certain features until the user verifies; or no access at all. + +Verification tokens are [JSON Web Tokens](https://jwt.io/) generated and processed by the [jwt](https://github.com/golang-jwt/jwt) module. The tokens are _signed_ using the encryption key stored in [configuration](#configuration) (`Config.App.EncryptionKey`). **It is imperative** that you override this value from the default in any live environments otherwise the data can be comprimised. JWT was chosen because they are secure tokens that do not have to be stored in the database, since the tokens contain all of the data required, including built-in expirations. These were not chosen for password reset tokens because JWT cannot be withdrawn once they are issued which poses a security risk. Since these tokens do not grant access to an account, the ability to withdraw the tokens is not needed. + +By default, verification tokens expire 12 hours after they are issued. This can be changed in configuration at `Config.App.EmailVerificationTokenExpiration`. There is currently not a route or form provided to request a new link. + +Be sure to review the [email](#email) section since actual email sending is not fully implemented. + +To generate a new verification token, the `AuthClient` has a method `GenerateEmailVerificationToken()` which creates a token for a given email address. To verify the token, pass it in to `ValidateEmailVerificationToken()` which will return the email address associated with the token and an error if the token is invalid. + ## Routes The router functionality is provided by [Echo](https://echo.labstack.com/guide/routing/) and constructed within via the `BuildRouter()` function inside `routes/router.go`. Since the _Echo_ instance is a _Service_ on the `Container` which is passed in to `BuildRouter()`, middleware and routes can be added directly to it. @@ -920,7 +935,6 @@ By default, Echo's [request ID middleware](https://echo.labstack.com/middleware/ Future work includes but is not limited to: -- Email verification - Flexible pager templates - Expanded HTMX examples and integration - Admin section @@ -929,21 +943,22 @@ Future work includes but is not limited to: Thank you to all of the following amazing projects for making this possible. -- [go](https://go.dev/) -- [echo](https://github.com/labstack/echo) -- [ent](https://github.com/ent/ent) -- [sprig](https://github.com/Masterminds/sprig) -- [goquery](https://github.com/PuerkitoBio/goquery) -- [validator](https://github.com/go-playground/validator) -- [go-redis](https://github.com/go-redis/redis) -- [gocache](https://github.com/eko/gocache) -- [sessions](https://github.com/gorilla/sessions) -- [pgx](https://github.com/jackc/pgx) -- [envdecode](https://github.com/joeshaw/envdecode) -- [testify](https://github.com/stretchr/testify) -- [htmx](https://github.com/bigskysoftware/htmx) - [alpinejs](https://github.com/alpinejs/alpine) - [bulma](https://github.com/jgthms/bulma) - [docker](https://www.docker.com/) +- [echo](https://github.com/labstack/echo) +- [ent](https://github.com/ent/ent) +- [envdecode](https://github.com/joeshaw/envdecode) +- [go](https://go.dev/) +- [gocache](https://github.com/eko/gocache) +- [goquery](https://github.com/PuerkitoBio/goquery) +- [go-redis](https://github.com/go-redis/redis) +- [htmx](https://github.com/bigskysoftware/htmx) +- [jwt](https://github.com/golang-jwt/jwt) +- [pgx](https://github.com/jackc/pgx) - [postgresql](https://www.postgresql.org/) -- [redis](https://redis.io/) \ No newline at end of file +- [redis](https://redis.io/) +- [sprig](https://github.com/Masterminds/sprig) +- [sessions](https://github.com/gorilla/sessions) +- [testify](https://github.com/stretchr/testify) +- [validator](https://github.com/go-playground/validator) \ No newline at end of file diff --git a/config/config.go b/config/config.go index e70b0b1..667ed62 100644 --- a/config/config.go +++ b/config/config.go @@ -67,14 +67,16 @@ type ( // AppConfig stores application configuration AppConfig struct { - Name string `env:"APP_NAME,default=Pagoda"` - Environment Environment `env:"APP_ENVIRONMENT,default=local"` + Name string `env:"APP_NAME,default=Pagoda"` + Environment Environment `env:"APP_ENVIRONMENT,default=local"` + // THIS MUST BE OVERRIDDEN ON ANY LIVE ENVIRONMENTS EncryptionKey string `env:"APP_ENCRYPTION_KEY,default=?E(G+KbPeShVmYq3t6w9z$C&F)J@McQf"` Timeout time.Duration `env:"APP_TIMEOUT,default=20s"` PasswordToken struct { Expiration time.Duration `env:"APP_PASSWORD_TOKEN_EXPIRATION,default=60m"` Length int `env:"APP_PASSWORD_TOKEN_LENGTH,default=64"` } + EmailVerificationTokenExpiration time.Duration `env:"APP_EMAIL_VERIFICATION_TOKEN_EXPIRATION,default=12h"` } // CacheConfig stores the cache configuration diff --git a/ent/ent.go b/ent/ent.go index cfd8400..7fac54a 100644 --- a/ent/ent.go +++ b/ent/ent.go @@ -6,11 +6,10 @@ import ( "errors" "fmt" - "github.com/mikestefanello/pagoda/ent/passwordtoken" - "github.com/mikestefanello/pagoda/ent/user" - "entgo.io/ent" "entgo.io/ent/dialect/sql" + "github.com/mikestefanello/pagoda/ent/passwordtoken" + "github.com/mikestefanello/pagoda/ent/user" ) // ent aliases to avoid import conflicts in user's code. diff --git a/ent/enttest/enttest.go b/ent/enttest/enttest.go index 0d124a9..5698009 100644 --- a/ent/enttest/enttest.go +++ b/ent/enttest/enttest.go @@ -6,7 +6,6 @@ import ( "context" "github.com/mikestefanello/pagoda/ent" - // required by schema hooks. _ "github.com/mikestefanello/pagoda/ent/runtime" diff --git a/ent/migrate/schema.go b/ent/migrate/schema.go index 005da13..4c8d409 100644 --- a/ent/migrate/schema.go +++ b/ent/migrate/schema.go @@ -35,6 +35,7 @@ var ( {Name: "name", Type: field.TypeString}, {Name: "email", Type: field.TypeString, Unique: true}, {Name: "password", Type: field.TypeString}, + {Name: "verified", Type: field.TypeBool, Default: false}, {Name: "created_at", Type: field.TypeTime}, } // UsersTable holds the schema information for the "users" table. diff --git a/ent/mutation.go b/ent/mutation.go index 766209b..1374480 100644 --- a/ent/mutation.go +++ b/ent/mutation.go @@ -452,6 +452,7 @@ type UserMutation struct { name *string email *string password *string + verified *bool created_at *time.Time clearedFields map[string]struct{} owner map[int]struct{} @@ -649,6 +650,42 @@ func (m *UserMutation) ResetPassword() { m.password = nil } +// SetVerified sets the "verified" field. +func (m *UserMutation) SetVerified(b bool) { + m.verified = &b +} + +// Verified returns the value of the "verified" field in the mutation. +func (m *UserMutation) Verified() (r bool, exists bool) { + v := m.verified + if v == nil { + return + } + return *v, true +} + +// OldVerified returns the old "verified" field's value of the User entity. +// If the User object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *UserMutation) OldVerified(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, fmt.Errorf("OldVerified is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, fmt.Errorf("OldVerified requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldVerified: %w", err) + } + return oldValue.Verified, nil +} + +// ResetVerified resets all changes to the "verified" field. +func (m *UserMutation) ResetVerified() { + m.verified = nil +} + // SetCreatedAt sets the "created_at" field. func (m *UserMutation) SetCreatedAt(t time.Time) { m.created_at = &t @@ -758,7 +795,7 @@ func (m *UserMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *UserMutation) Fields() []string { - fields := make([]string, 0, 4) + fields := make([]string, 0, 5) if m.name != nil { fields = append(fields, user.FieldName) } @@ -768,6 +805,9 @@ func (m *UserMutation) Fields() []string { if m.password != nil { fields = append(fields, user.FieldPassword) } + if m.verified != nil { + fields = append(fields, user.FieldVerified) + } if m.created_at != nil { fields = append(fields, user.FieldCreatedAt) } @@ -785,6 +825,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) { return m.Email() case user.FieldPassword: return m.Password() + case user.FieldVerified: + return m.Verified() case user.FieldCreatedAt: return m.CreatedAt() } @@ -802,6 +844,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er return m.OldEmail(ctx) case user.FieldPassword: return m.OldPassword(ctx) + case user.FieldVerified: + return m.OldVerified(ctx) case user.FieldCreatedAt: return m.OldCreatedAt(ctx) } @@ -834,6 +878,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error { } m.SetPassword(v) return nil + case user.FieldVerified: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetVerified(v) + return nil case user.FieldCreatedAt: v, ok := value.(time.Time) if !ok { @@ -899,6 +950,9 @@ func (m *UserMutation) ResetField(name string) error { case user.FieldPassword: m.ResetPassword() return nil + case user.FieldVerified: + m.ResetVerified() + return nil case user.FieldCreatedAt: m.ResetCreatedAt() return nil diff --git a/ent/passwordtoken.go b/ent/passwordtoken.go index d28d7a9..ebae46c 100644 --- a/ent/passwordtoken.go +++ b/ent/passwordtoken.go @@ -7,10 +7,9 @@ import ( "strings" "time" + "entgo.io/ent/dialect/sql" "github.com/mikestefanello/pagoda/ent/passwordtoken" "github.com/mikestefanello/pagoda/ent/user" - - "entgo.io/ent/dialect/sql" ) // PasswordToken is the model entity for the PasswordToken schema. diff --git a/ent/passwordtoken/where.go b/ent/passwordtoken/where.go index bcbc8b5..ff427d4 100644 --- a/ent/passwordtoken/where.go +++ b/ent/passwordtoken/where.go @@ -5,10 +5,9 @@ package passwordtoken import ( "time" - "github.com/mikestefanello/pagoda/ent/predicate" - "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/mikestefanello/pagoda/ent/predicate" ) // ID filters vertices based on their ID field. diff --git a/ent/passwordtoken_create.go b/ent/passwordtoken_create.go index 6cfd214..68e0bd6 100644 --- a/ent/passwordtoken_create.go +++ b/ent/passwordtoken_create.go @@ -8,11 +8,10 @@ import ( "fmt" "time" - "github.com/mikestefanello/pagoda/ent/passwordtoken" - "github.com/mikestefanello/pagoda/ent/user" - "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" + "github.com/mikestefanello/pagoda/ent/passwordtoken" + "github.com/mikestefanello/pagoda/ent/user" ) // PasswordTokenCreate is the builder for creating a PasswordToken entity. diff --git a/ent/passwordtoken_delete.go b/ent/passwordtoken_delete.go index b97dd59..5eed7ac 100644 --- a/ent/passwordtoken_delete.go +++ b/ent/passwordtoken_delete.go @@ -6,12 +6,11 @@ import ( "context" "fmt" - "github.com/mikestefanello/pagoda/ent/passwordtoken" - "github.com/mikestefanello/pagoda/ent/predicate" - "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" + "github.com/mikestefanello/pagoda/ent/passwordtoken" + "github.com/mikestefanello/pagoda/ent/predicate" ) // PasswordTokenDelete is the builder for deleting a PasswordToken entity. diff --git a/ent/passwordtoken_query.go b/ent/passwordtoken_query.go index f67db3d..e56cbac 100644 --- a/ent/passwordtoken_query.go +++ b/ent/passwordtoken_query.go @@ -8,13 +8,12 @@ import ( "fmt" "math" - "github.com/mikestefanello/pagoda/ent/passwordtoken" - "github.com/mikestefanello/pagoda/ent/predicate" - "github.com/mikestefanello/pagoda/ent/user" - "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" + "github.com/mikestefanello/pagoda/ent/passwordtoken" + "github.com/mikestefanello/pagoda/ent/predicate" + "github.com/mikestefanello/pagoda/ent/user" ) // PasswordTokenQuery is the builder for querying PasswordToken entities. diff --git a/ent/passwordtoken_update.go b/ent/passwordtoken_update.go index 3f5566c..5164aa1 100644 --- a/ent/passwordtoken_update.go +++ b/ent/passwordtoken_update.go @@ -8,13 +8,12 @@ import ( "fmt" "time" - "github.com/mikestefanello/pagoda/ent/passwordtoken" - "github.com/mikestefanello/pagoda/ent/predicate" - "github.com/mikestefanello/pagoda/ent/user" - "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" + "github.com/mikestefanello/pagoda/ent/passwordtoken" + "github.com/mikestefanello/pagoda/ent/predicate" + "github.com/mikestefanello/pagoda/ent/user" ) // PasswordTokenUpdate is the builder for updating PasswordToken entities. diff --git a/ent/runtime.go b/ent/runtime.go index e4bc5f5..89cada3 100644 --- a/ent/runtime.go +++ b/ent/runtime.go @@ -2,4 +2,4 @@ package ent -// The schema-stitching logic is generated in goweb/ent/runtime/runtime.go +// The schema-stitching logic is generated in github.com/mikestefanello/pagoda/ent/runtime/runtime.go diff --git a/ent/runtime/runtime.go b/ent/runtime/runtime.go index 0147026..9989d0a 100644 --- a/ent/runtime/runtime.go +++ b/ent/runtime/runtime.go @@ -40,8 +40,12 @@ func init() { userDescPassword := userFields[2].Descriptor() // user.PasswordValidator is a validator for the "password" field. It is called by the builders before save. user.PasswordValidator = userDescPassword.Validators[0].(func(string) error) + // userDescVerified is the schema descriptor for verified field. + userDescVerified := userFields[3].Descriptor() + // user.DefaultVerified holds the default value on creation for the verified field. + user.DefaultVerified = userDescVerified.Default.(bool) // userDescCreatedAt is the schema descriptor for created_at field. - userDescCreatedAt := userFields[3].Descriptor() + userDescCreatedAt := userFields[4].Descriptor() // user.DefaultCreatedAt holds the default value on creation for the created_at field. user.DefaultCreatedAt = userDescCreatedAt.Default.(func() time.Time) } diff --git a/ent/schema/user.go b/ent/schema/user.go index 7522a77..ffd80f5 100644 --- a/ent/schema/user.go +++ b/ent/schema/user.go @@ -29,6 +29,8 @@ func (User) Fields() []ent.Field { field.String("password"). Sensitive(). NotEmpty(), + field.Bool("verified"). + Default(false), field.Time("created_at"). Default(time.Now). Immutable(), diff --git a/ent/user.go b/ent/user.go index 5ac1079..c24c70e 100644 --- a/ent/user.go +++ b/ent/user.go @@ -7,9 +7,8 @@ import ( "strings" "time" - "github.com/mikestefanello/pagoda/ent/user" - "entgo.io/ent/dialect/sql" + "github.com/mikestefanello/pagoda/ent/user" ) // User is the model entity for the User schema. @@ -23,6 +22,8 @@ type User struct { Email string `json:"email,omitempty"` // Password holds the value of the "password" field. Password string `json:"-"` + // Verified holds the value of the "verified" field. + Verified bool `json:"verified,omitempty"` // CreatedAt holds the value of the "created_at" field. CreatedAt time.Time `json:"created_at,omitempty"` // Edges holds the relations/edges for other nodes in the graph. @@ -53,6 +54,8 @@ func (*User) scanValues(columns []string) ([]interface{}, error) { values := make([]interface{}, len(columns)) for i := range columns { switch columns[i] { + case user.FieldVerified: + values[i] = new(sql.NullBool) case user.FieldID: values[i] = new(sql.NullInt64) case user.FieldName, user.FieldEmail, user.FieldPassword: @@ -98,6 +101,12 @@ func (u *User) assignValues(columns []string, values []interface{}) error { } else if value.Valid { u.Password = value.String } + case user.FieldVerified: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field verified", values[i]) + } else if value.Valid { + u.Verified = value.Bool + } case user.FieldCreatedAt: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field created_at", values[i]) @@ -142,6 +151,8 @@ func (u *User) String() string { builder.WriteString(", email=") builder.WriteString(u.Email) builder.WriteString(", password=") + builder.WriteString(", verified=") + builder.WriteString(fmt.Sprintf("%v", u.Verified)) builder.WriteString(", created_at=") builder.WriteString(u.CreatedAt.Format(time.ANSIC)) builder.WriteByte(')') diff --git a/ent/user/user.go b/ent/user/user.go index 4d7a487..1d02226 100644 --- a/ent/user/user.go +++ b/ent/user/user.go @@ -19,6 +19,8 @@ const ( FieldEmail = "email" // FieldPassword holds the string denoting the password field in the database. FieldPassword = "password" + // FieldVerified holds the string denoting the verified field in the database. + FieldVerified = "verified" // FieldCreatedAt holds the string denoting the created_at field in the database. FieldCreatedAt = "created_at" // EdgeOwner holds the string denoting the owner edge name in mutations. @@ -40,6 +42,7 @@ var Columns = []string{ FieldName, FieldEmail, FieldPassword, + FieldVerified, FieldCreatedAt, } @@ -57,7 +60,7 @@ func ValidColumn(column string) bool { // package on the initialization of the application. Therefore, // it should be imported in the main as follows: // -// import _ "goweb/ent/runtime" +// import _ "github.com/mikestefanello/pagoda/ent/runtime" // var ( Hooks [1]ent.Hook @@ -67,6 +70,8 @@ var ( EmailValidator func(string) error // PasswordValidator is a validator for the "password" field. It is called by the builders before save. PasswordValidator func(string) error + // DefaultVerified holds the default value on creation for the "verified" field. + DefaultVerified bool // DefaultCreatedAt holds the default value on creation for the "created_at" field. DefaultCreatedAt func() time.Time ) diff --git a/ent/user/where.go b/ent/user/where.go index ea70676..a7a32dd 100644 --- a/ent/user/where.go +++ b/ent/user/where.go @@ -5,10 +5,9 @@ package user import ( "time" - "github.com/mikestefanello/pagoda/ent/predicate" - "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/mikestefanello/pagoda/ent/predicate" ) // ID filters vertices based on their ID field. @@ -115,6 +114,13 @@ func Password(v string) predicate.User { }) } +// Verified applies equality check predicate on the "verified" field. It's identical to VerifiedEQ. +func Verified(v bool) predicate.User { + return predicate.User(func(s *sql.Selector) { + s.Where(sql.EQ(s.C(FieldVerified), v)) + }) +} + // CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. func CreatedAt(v time.Time) predicate.User { return predicate.User(func(s *sql.Selector) { @@ -455,6 +461,20 @@ func PasswordContainsFold(v string) predicate.User { }) } +// VerifiedEQ applies the EQ predicate on the "verified" field. +func VerifiedEQ(v bool) predicate.User { + return predicate.User(func(s *sql.Selector) { + s.Where(sql.EQ(s.C(FieldVerified), v)) + }) +} + +// VerifiedNEQ applies the NEQ predicate on the "verified" field. +func VerifiedNEQ(v bool) predicate.User { + return predicate.User(func(s *sql.Selector) { + s.Where(sql.NEQ(s.C(FieldVerified), v)) + }) +} + // CreatedAtEQ applies the EQ predicate on the "created_at" field. func CreatedAtEQ(v time.Time) predicate.User { return predicate.User(func(s *sql.Selector) { diff --git a/ent/user_create.go b/ent/user_create.go index 9fb4ab9..49b8191 100644 --- a/ent/user_create.go +++ b/ent/user_create.go @@ -8,11 +8,10 @@ import ( "fmt" "time" - "github.com/mikestefanello/pagoda/ent/passwordtoken" - "github.com/mikestefanello/pagoda/ent/user" - "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" + "github.com/mikestefanello/pagoda/ent/passwordtoken" + "github.com/mikestefanello/pagoda/ent/user" ) // UserCreate is the builder for creating a User entity. @@ -40,6 +39,20 @@ func (uc *UserCreate) SetPassword(s string) *UserCreate { return uc } +// SetVerified sets the "verified" field. +func (uc *UserCreate) SetVerified(b bool) *UserCreate { + uc.mutation.SetVerified(b) + return uc +} + +// SetNillableVerified sets the "verified" field if the given value is not nil. +func (uc *UserCreate) SetNillableVerified(b *bool) *UserCreate { + if b != nil { + uc.SetVerified(*b) + } + return uc +} + // SetCreatedAt sets the "created_at" field. func (uc *UserCreate) SetCreatedAt(t time.Time) *UserCreate { uc.mutation.SetCreatedAt(t) @@ -142,6 +155,10 @@ func (uc *UserCreate) ExecX(ctx context.Context) { // defaults sets the default values of the builder before save. func (uc *UserCreate) defaults() error { + if _, ok := uc.mutation.Verified(); !ok { + v := user.DefaultVerified + uc.mutation.SetVerified(v) + } if _, ok := uc.mutation.CreatedAt(); !ok { if user.DefaultCreatedAt == nil { return fmt.Errorf("ent: uninitialized user.DefaultCreatedAt (forgotten import ent/runtime?)") @@ -178,6 +195,9 @@ func (uc *UserCreate) check() error { return &ValidationError{Name: "password", err: fmt.Errorf(`ent: validator failed for field "password": %w`, err)} } } + if _, ok := uc.mutation.Verified(); !ok { + return &ValidationError{Name: "verified", err: errors.New(`ent: missing required field "verified"`)} + } if _, ok := uc.mutation.CreatedAt(); !ok { return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "created_at"`)} } @@ -232,6 +252,14 @@ func (uc *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { }) _node.Password = value } + if value, ok := uc.mutation.Verified(); ok { + _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ + Type: field.TypeBool, + Value: value, + Column: user.FieldVerified, + }) + _node.Verified = value + } if value, ok := uc.mutation.CreatedAt(); ok { _spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{ Type: field.TypeTime, diff --git a/ent/user_delete.go b/ent/user_delete.go index 29047bf..f8734c6 100644 --- a/ent/user_delete.go +++ b/ent/user_delete.go @@ -6,12 +6,11 @@ import ( "context" "fmt" - "github.com/mikestefanello/pagoda/ent/predicate" - "github.com/mikestefanello/pagoda/ent/user" - "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" + "github.com/mikestefanello/pagoda/ent/predicate" + "github.com/mikestefanello/pagoda/ent/user" ) // UserDelete is the builder for deleting a User entity. diff --git a/ent/user_query.go b/ent/user_query.go index ad2bf1f..a60bc23 100644 --- a/ent/user_query.go +++ b/ent/user_query.go @@ -9,13 +9,12 @@ import ( "fmt" "math" - "github.com/mikestefanello/pagoda/ent/passwordtoken" - "github.com/mikestefanello/pagoda/ent/predicate" - "github.com/mikestefanello/pagoda/ent/user" - "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" + "github.com/mikestefanello/pagoda/ent/passwordtoken" + "github.com/mikestefanello/pagoda/ent/predicate" + "github.com/mikestefanello/pagoda/ent/user" ) // UserQuery is the builder for querying User entities. diff --git a/ent/user_update.go b/ent/user_update.go index a6826ea..80ab9dd 100644 --- a/ent/user_update.go +++ b/ent/user_update.go @@ -6,13 +6,12 @@ import ( "context" "fmt" - "github.com/mikestefanello/pagoda/ent/passwordtoken" - "github.com/mikestefanello/pagoda/ent/predicate" - "github.com/mikestefanello/pagoda/ent/user" - "entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" + "github.com/mikestefanello/pagoda/ent/passwordtoken" + "github.com/mikestefanello/pagoda/ent/predicate" + "github.com/mikestefanello/pagoda/ent/user" ) // UserUpdate is the builder for updating User entities. @@ -46,6 +45,20 @@ func (uu *UserUpdate) SetPassword(s string) *UserUpdate { return uu } +// SetVerified sets the "verified" field. +func (uu *UserUpdate) SetVerified(b bool) *UserUpdate { + uu.mutation.SetVerified(b) + return uu +} + +// SetNillableVerified sets the "verified" field if the given value is not nil. +func (uu *UserUpdate) SetNillableVerified(b *bool) *UserUpdate { + if b != nil { + uu.SetVerified(*b) + } + return uu +} + // AddOwnerIDs adds the "owner" edge to the PasswordToken entity by IDs. func (uu *UserUpdate) AddOwnerIDs(ids ...int) *UserUpdate { uu.mutation.AddOwnerIDs(ids...) @@ -206,6 +219,13 @@ func (uu *UserUpdate) sqlSave(ctx context.Context) (n int, err error) { Column: user.FieldPassword, }) } + if value, ok := uu.mutation.Verified(); ok { + _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ + Type: field.TypeBool, + Value: value, + Column: user.FieldVerified, + }) + } if uu.mutation.OwnerCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -297,6 +317,20 @@ func (uuo *UserUpdateOne) SetPassword(s string) *UserUpdateOne { return uuo } +// SetVerified sets the "verified" field. +func (uuo *UserUpdateOne) SetVerified(b bool) *UserUpdateOne { + uuo.mutation.SetVerified(b) + return uuo +} + +// SetNillableVerified sets the "verified" field if the given value is not nil. +func (uuo *UserUpdateOne) SetNillableVerified(b *bool) *UserUpdateOne { + if b != nil { + uuo.SetVerified(*b) + } + return uuo +} + // AddOwnerIDs adds the "owner" edge to the PasswordToken entity by IDs. func (uuo *UserUpdateOne) AddOwnerIDs(ids ...int) *UserUpdateOne { uuo.mutation.AddOwnerIDs(ids...) @@ -481,6 +515,13 @@ func (uuo *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) Column: user.FieldPassword, }) } + if value, ok := uuo.mutation.Verified(); ok { + _spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{ + Type: field.TypeBool, + Value: value, + Column: user.FieldVerified, + }) + } if uuo.mutation.OwnerCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/go.mod b/go.mod index fdd65a2..8e63b39 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-playground/assert/v2 v2.0.1 github.com/go-playground/validator/v10 v10.9.0 github.com/go-redis/redis/v8 v8.11.4 + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/gorilla/sessions v1.2.1 github.com/jackc/pgx/v4 v4.14.1 github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd @@ -33,7 +34,6 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect - github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/uuid v1.3.0 // indirect github.com/gorilla/context v1.1.1 // indirect diff --git a/go.sum b/go.sum index b1b4126..f9187dd 100644 --- a/go.sum +++ b/go.sum @@ -150,7 +150,6 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= -github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4= github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4= github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= @@ -404,7 +403,6 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= @@ -447,7 +445,6 @@ github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtb github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -565,13 +562,11 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= -github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M= github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= @@ -671,7 +666,6 @@ golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKG golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -818,7 +812,6 @@ golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.5 h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/routes/register.go b/routes/register.go index 6e22111..619c866 100644 --- a/routes/register.go +++ b/routes/register.go @@ -1,6 +1,8 @@ package routes import ( + "fmt" + "github.com/mikestefanello/pagoda/context" "github.com/mikestefanello/pagoda/controller" "github.com/mikestefanello/pagoda/ent" @@ -86,6 +88,31 @@ func (c *Register) Post(ctx echo.Context) error { return c.Redirect(ctx, "login") } - msg.Info(ctx, "Your account has been created. You are now logged in.") + msg.Success(ctx, "Your account has been created. You are now logged in.") + + // Send the verification email + c.sendVerificationEmail(ctx, u) + return c.Redirect(ctx, "home") } + +func (c *Register) sendVerificationEmail(ctx echo.Context, usr *ent.User) { + // Generate a token + token, err := c.Container.Auth.GenerateEmailVerificationToken(usr.Email) + if err != nil { + ctx.Logger().Errorf("unable to generate email verification token: %v", err) + return + } + + // Send the email + err = c.Container.Mail.Send(ctx, usr.Email, fmt.Sprintf( + "Confirm your email address: %s", + ctx.Echo().Reverse("verify_email", token), + )) + if err != nil { + ctx.Logger().Errorf("unable to send email verification token: %v", err) + return + } + + msg.Info(ctx, "An email was sent to you to verify your email address.") +} diff --git a/routes/router.go b/routes/router.go index 47454d8..5fc6414 100644 --- a/routes/router.go +++ b/routes/router.go @@ -83,6 +83,9 @@ func userRoutes(c *services.Container, g *echo.Group, ctr controller.Controller) logout := Logout{Controller: ctr} g.GET("/logout", logout.Get, middleware.RequireAuthentication()).Name = "logout" + verifyEmail := VerifyEmail{Controller: ctr} + g.GET("/email/verify/:token", verifyEmail.Get).Name = "verify_email" + noAuth := g.Group("/user", middleware.RequireNoAuthentication()) login := Login{Controller: ctr} noAuth.GET("/login", login.Get).Name = "login" diff --git a/routes/verify_email.go b/routes/verify_email.go new file mode 100644 index 0000000..f399469 --- /dev/null +++ b/routes/verify_email.go @@ -0,0 +1,70 @@ +package routes + +import ( + "github.com/labstack/echo/v4" + "github.com/mikestefanello/pagoda/context" + "github.com/mikestefanello/pagoda/controller" + "github.com/mikestefanello/pagoda/ent" + "github.com/mikestefanello/pagoda/ent/user" + "github.com/mikestefanello/pagoda/msg" +) + +type VerifyEmail struct { + controller.Controller +} + +func (c *VerifyEmail) Get(ctx echo.Context) error { + c.verifyToken(ctx) + + return c.Redirect(ctx, "home") +} + +func (c *VerifyEmail) verifyToken(ctx echo.Context) { + var usr *ent.User + + // Validate the token + token := ctx.Param("token") + email, err := c.Container.Auth.ValidateEmailVerificationToken(token) + if err != nil { + msg.Warning(ctx, "The link is either invalid or has expired.") + return + } + + // Check if it matches the authenticated user + if u := ctx.Get(context.AuthenticatedUserKey); u != nil { + authUser := u.(*ent.User) + + if authUser.Email == email { + usr = authUser + } + } + + // Query to find a matching user, if needed + if usr == nil { + usr, err = c.Container.ORM.User. + Query(). + Where(user.Email(email)). + Only(ctx.Request().Context()) + + if err != nil { + ctx.Logger().Errorf("error querying user during email verification: %v", err) + msg.Danger(ctx, "An error occurred. Please try again.") + return + } + } + + // Verify the user + err = c.Container.ORM.User. + Update(). + SetVerified(true). + Where(user.ID(usr.ID)). + Exec(ctx.Request().Context()) + + if err != nil { + ctx.Logger().Errorf("error setting user as verified: %v", err) + msg.Danger(ctx, "An error occurred. Please try again.") + return + } + + msg.Success(ctx, "You email has been successfully verified.") +} diff --git a/services/auth.go b/services/auth.go index 94acba7..e9ccfd3 100644 --- a/services/auth.go +++ b/services/auth.go @@ -3,8 +3,11 @@ package services import ( "crypto/rand" "encoding/hex" + "errors" + "fmt" "time" + "github.com/golang-jwt/jwt" "github.com/mikestefanello/pagoda/config" "github.com/mikestefanello/pagoda/ent" "github.com/mikestefanello/pagoda/ent/passwordtoken" @@ -194,3 +197,36 @@ func (c *AuthClient) RandomToken(length int) (string, error) { token := hex.EncodeToString(b) return token[:length], nil } + +// GenerateEmailVerificationToken generates an email verification token for a given email address using JWT which +// is set to expire based on the duration stored in configuration +func (c *AuthClient) GenerateEmailVerificationToken(email string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "email": email, + "exp": time.Now().Add(c.config.App.EmailVerificationTokenExpiration).Unix(), + }) + + return token.SignedString([]byte(c.config.App.EncryptionKey)) +} + +// ValidateEmailVerificationToken validates an email verification token and returns the associated email address if +// the token is valid and has not expired +func (c *AuthClient) ValidateEmailVerificationToken(token string) (string, error) { + t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + + return []byte(c.config.App.EncryptionKey), nil + }) + + if err != nil { + return "", err + } + + if claims, ok := t.Claims.(jwt.MapClaims); ok && t.Valid { + return claims["email"].(string), nil + } + + return "", errors.New("invalid or expired token") +} diff --git a/services/auth_test.go b/services/auth_test.go index c56c8c9..d18ced8 100644 --- a/services/auth_test.go +++ b/services/auth_test.go @@ -14,7 +14,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestAuth(t *testing.T) { +func TestAuthClient_Auth(t *testing.T) { assertNoAuth := func() { _, err := c.Auth.GetAuthenticatedUserID(ctx) assert.True(t, errors.Is(err, NotAuthenticatedError{})) @@ -41,7 +41,7 @@ func TestAuth(t *testing.T) { assertNoAuth() } -func TestPasswordHashing(t *testing.T) { +func TestAuthClient_PasswordHashing(t *testing.T) { pw := "testcheckpassword" hash, err := c.Auth.HashPassword(pw) assert.NoError(t, err) @@ -50,14 +50,14 @@ func TestPasswordHashing(t *testing.T) { assert.NoError(t, err) } -func TestGeneratePasswordResetToken(t *testing.T) { +func TestAuthClient_GeneratePasswordResetToken(t *testing.T) { token, pt, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID) require.NoError(t, err) assert.Len(t, token, c.Config.App.PasswordToken.Length) assert.NoError(t, c.Auth.CheckPassword(token, pt.Hash)) } -func TestGetValidPasswordToken(t *testing.T) { +func TestAuthClient_GetValidPasswordToken(t *testing.T) { // Check that a fake token is not valid _, err := c.Auth.GetValidPasswordToken(ctx, "faketoken", usr.ID) assert.Error(t, err) @@ -82,7 +82,7 @@ func TestGetValidPasswordToken(t *testing.T) { assert.Error(t, err) } -func TestDeletePasswordTokens(t *testing.T) { +func TestAuthClient_DeletePasswordTokens(t *testing.T) { // Create three tokens for the user for i := 0; i < 3; i++ { _, _, err := c.Auth.GeneratePasswordResetToken(ctx, usr.ID) @@ -103,7 +103,7 @@ func TestDeletePasswordTokens(t *testing.T) { assert.Equal(t, 0, count) } -func TestRandomToken(t *testing.T) { +func TestAuthClient_RandomToken(t *testing.T) { length := c.Config.App.PasswordToken.Length a, err := c.Auth.RandomToken(length) require.NoError(t, err) @@ -113,3 +113,33 @@ func TestRandomToken(t *testing.T) { assert.Len(t, b, length) assert.NotEqual(t, a, b) } + +func TestAuthClient_EmailVerificationToken(t *testing.T) { + t.Run("valid token", func(t *testing.T) { + email := "test@localhost.com" + token, err := c.Auth.GenerateEmailVerificationToken(email) + require.NoError(t, err) + + tokenEmail, err := c.Auth.ValidateEmailVerificationToken(token) + require.NoError(t, err) + assert.Equal(t, email, tokenEmail) + }) + + t.Run("invalid token", func(t *testing.T) { + badToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAbG9jYWxob3N0LmNvbSIsImV4cCI6MTkxNzg2NDAwMH0.ScJCpfEEzlilKfRs_aVouzwPNKI28M3AIm-hyImQHUQ" + _, err := c.Auth.ValidateEmailVerificationToken(badToken) + assert.Error(t, err) + }) + + t.Run("expired token", func(t *testing.T) { + c.Config.App.EmailVerificationTokenExpiration = -time.Hour + email := "test@localhost.com" + token, err := c.Auth.GenerateEmailVerificationToken(email) + require.NoError(t, err) + + _, err = c.Auth.ValidateEmailVerificationToken(token) + assert.Error(t, err) + + c.Config.App.EmailVerificationTokenExpiration = time.Hour * 12 + }) +} diff --git a/templates/components/core.gohtml b/templates/components/core.gohtml index 9a05200..b8174dc 100644 --- a/templates/components/core.gohtml +++ b/templates/components/core.gohtml @@ -26,7 +26,7 @@ diff --git a/templates/layouts/main.gohtml b/templates/layouts/main.gohtml index a8c98af..2b04046 100644 --- a/templates/layouts/main.gohtml +++ b/templates/layouts/main.gohtml @@ -70,14 +70,14 @@

Search