Merge pull request #8 from anthdm/email-verification
wip - email verification
This commit is contained in:
commit
27c345f138
17 changed files with 269 additions and 67 deletions
|
@ -25,5 +25,6 @@ SUPERKIT_SECRET = {{app_secret}}
|
||||||
# Authentication Plugin
|
# Authentication Plugin
|
||||||
SUPERKIT_AUTH_REDIRECT_AFTER_LOGIN = /profile
|
SUPERKIT_AUTH_REDIRECT_AFTER_LOGIN = /profile
|
||||||
SUPERKIT_AUTH_SESSION_EXPIRY_IN_HOURS = 48
|
SUPERKIT_AUTH_SESSION_EXPIRY_IN_HOURS = 48
|
||||||
# Skip user email verification
|
# Skip user email verification after signup
|
||||||
SUPERKIT_AUTH_SKIP_VERIFY = true
|
SUPERKIT_AUTH_SKIP_VERIFY = false
|
||||||
|
SUPERKIT_AUTH_EMAIL_VERIFICATION_EXPIRY_IN_HOURS = 1
|
||||||
|
|
|
@ -2,6 +2,7 @@ package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"AABBCCDD/app/events"
|
"AABBCCDD/app/events"
|
||||||
|
"AABBCCDD/plugins/auth"
|
||||||
|
|
||||||
"github.com/anthdm/superkit/event"
|
"github.com/anthdm/superkit/event"
|
||||||
)
|
)
|
||||||
|
@ -15,5 +16,6 @@ import (
|
||||||
|
|
||||||
// Register your events here.
|
// Register your events here.
|
||||||
func RegisterEvents() {
|
func RegisterEvents() {
|
||||||
event.Subscribe("foo.bar", events.HandleFooEvent)
|
event.Subscribe(auth.UserSignupEvent, events.OnUserSignup)
|
||||||
|
event.Subscribe(auth.ResendVerificationEvent, events.OnResendVerificationToken)
|
||||||
}
|
}
|
||||||
|
|
27
bootstrap/app/events/auth.go
Normal file
27
bootstrap/app/events/auth.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"AABBCCDD/plugins/auth"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
func OnUserSignup(ctx context.Context, event any) {
|
||||||
|
userWithToken, ok := event.(auth.UserWithVerificationToken)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(userWithToken, " ", " ")
|
||||||
|
fmt.Println(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func OnResendVerificationToken(ctx context.Context, event any) {
|
||||||
|
userWithToken, ok := event.(auth.UserWithVerificationToken)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(userWithToken, " ", " ")
|
||||||
|
fmt.Println(string(b))
|
||||||
|
}
|
|
@ -1,6 +0,0 @@
|
||||||
package events
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
func HandleFooEvent(ctx context.Context, event any) {}
|
|
|
@ -22,9 +22,16 @@ func InitializeMiddleware(router *chi.Mux) {
|
||||||
|
|
||||||
// Define your routes in here
|
// Define your routes in here
|
||||||
func InitializeRoutes(router *chi.Mux) {
|
func InitializeRoutes(router *chi.Mux) {
|
||||||
// Authentication plugin:
|
// Authentication plugin
|
||||||
|
//
|
||||||
|
// By default the auth plugin is active. To disable the auth plugin
|
||||||
|
// you will need to pass your own handler in the `AuthFunc`` field
|
||||||
|
// of the `kit.AuthenticationConfig`.
|
||||||
|
// authConfig := kit.AuthenticationConfig{
|
||||||
|
// AuthFunc: YourAuthHandler,
|
||||||
|
// RedirectURL: "/login",
|
||||||
|
// }
|
||||||
auth.InitializeRoutes(router)
|
auth.InitializeRoutes(router)
|
||||||
|
|
||||||
authConfig := kit.AuthenticationConfig{
|
authConfig := kit.AuthenticationConfig{
|
||||||
AuthFunc: auth.AuthenticateUser,
|
AuthFunc: auth.AuthenticateUser,
|
||||||
RedirectURL: "/login",
|
RedirectURL: "/login",
|
||||||
|
|
|
@ -9,6 +9,7 @@ require (
|
||||||
github.com/a-h/templ v0.2.707
|
github.com/a-h/templ v0.2.707
|
||||||
github.com/anthdm/superkit v0.0.0-20240616155928-19996932bf4f
|
github.com/anthdm/superkit v0.0.0-20240616155928-19996932bf4f
|
||||||
github.com/go-chi/chi/v5 v5.0.12
|
github.com/go-chi/chi/v5 v5.0.12
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.22
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
|
@ -21,7 +22,7 @@ require (
|
||||||
require (
|
require (
|
||||||
github.com/fatih/color v1.16.0 // indirect
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/gorilla/sessions v1.2.2 // indirect
|
github.com/gorilla/sessions v1.3.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
github.com/a-h/templ v0.2.707 h1:T1Gkd2ugbRglZ9rYw/VBchWOSZVKmetDbBkm4YubM7U=
|
github.com/a-h/templ v0.2.707 h1:T1Gkd2ugbRglZ9rYw/VBchWOSZVKmetDbBkm4YubM7U=
|
||||||
github.com/a-h/templ v0.2.707/go.mod h1:5cqsugkq9IerRNucNsI4DEamdHPsoGMQy99DzydLhM8=
|
github.com/a-h/templ v0.2.707/go.mod h1:5cqsugkq9IerRNucNsI4DEamdHPsoGMQy99DzydLhM8=
|
||||||
github.com/anthdm/superkit v0.0.0-20240616155928-19996932bf4f h1:/zDgsfZcIHH4HNUBsKNNA3+IU7lGZoCc15V269FppK4=
|
|
||||||
github.com/anthdm/superkit v0.0.0-20240616155928-19996932bf4f/go.mod h1:bh0FK9XGdU86dbLEnMXVaIvmx0d8YRsjutPuDMi7Ni8=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
|
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
|
||||||
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
@ -16,8 +16,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
|
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
|
||||||
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
|
|
@ -2,9 +2,7 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"AABBCCDD/app/db"
|
"AABBCCDD/app/db"
|
||||||
"cmp"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
@ -12,6 +10,7 @@ import (
|
||||||
|
|
||||||
"github.com/anthdm/superkit/kit"
|
"github.com/anthdm/superkit/kit"
|
||||||
v "github.com/anthdm/superkit/validate"
|
v "github.com/anthdm/superkit/validate"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
@ -25,27 +24,15 @@ var authSchema = v.Schema{
|
||||||
"password": v.Rules(v.Required),
|
"password": v.Rules(v.Required),
|
||||||
}
|
}
|
||||||
|
|
||||||
var signupSchema = v.Schema{
|
func HandleLoginIndex(kit *kit.Kit) error {
|
||||||
"email": v.Rules(v.Email),
|
|
||||||
"password": v.Rules(
|
|
||||||
v.ContainsSpecial,
|
|
||||||
v.ContainsUpper,
|
|
||||||
v.Min(7),
|
|
||||||
v.Max(50),
|
|
||||||
),
|
|
||||||
"firstName": v.Rules(v.Min(2), v.Max(50)),
|
|
||||||
"lastName": v.Rules(v.Min(2), v.Max(50)),
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleAuthIndex(kit *kit.Kit) error {
|
|
||||||
if kit.Auth().Check() {
|
if kit.Auth().Check() {
|
||||||
redirectURL := cmp.Or(os.Getenv("SUPERKIT_AUTH_REDIRECT_AFTER_LOGIN"), "/profile")
|
redirectURL := kit.Getenv("SUPERKIT_AUTH_REDIRECT_AFTER_LOGIN", "/profile")
|
||||||
return kit.Redirect(http.StatusSeeOther, redirectURL)
|
return kit.Redirect(http.StatusSeeOther, redirectURL)
|
||||||
}
|
}
|
||||||
return kit.Render(AuthIndex(AuthIndexPageData{}))
|
return kit.Render(LoginIndex(LoginIndexPageData{}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleAuthCreate(kit *kit.Kit) error {
|
func HandleLoginCreate(kit *kit.Kit) error {
|
||||||
var values LoginFormValues
|
var values LoginFormValues
|
||||||
errors, ok := v.Request(kit.Request, &values, authSchema)
|
errors, ok := v.Request(kit.Request, &values, authSchema)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
@ -71,7 +58,6 @@ func HandleAuthCreate(kit *kit.Kit) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
skipVerify := kit.Getenv("SUPERKIT_AUTH_SKIP_VERIFY", "false")
|
skipVerify := kit.Getenv("SUPERKIT_AUTH_SKIP_VERIFY", "false")
|
||||||
fmt.Println(skipVerify)
|
|
||||||
if skipVerify != "true" {
|
if skipVerify != "true" {
|
||||||
if user.EmailVerifiedAt.Equal(time.Time{}) {
|
if user.EmailVerifiedAt.Equal(time.Time{}) {
|
||||||
errors.Add("verified", "please verify your email")
|
errors.Add("verified", "please verify your email")
|
||||||
|
@ -108,7 +94,7 @@ func HandleAuthCreate(kit *kit.Kit) error {
|
||||||
return kit.Redirect(http.StatusSeeOther, redirectURL)
|
return kit.Redirect(http.StatusSeeOther, redirectURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleAuthDelete(kit *kit.Kit) error {
|
func HandleLoginDelete(kit *kit.Kit) error {
|
||||||
sess := kit.GetSession(userSessionName)
|
sess := kit.GetSession(userSessionName)
|
||||||
defer func() {
|
defer func() {
|
||||||
sess.Values = map[any]any{}
|
sess.Values = map[any]any{}
|
||||||
|
@ -124,25 +110,59 @@ func HandleAuthDelete(kit *kit.Kit) error {
|
||||||
return kit.Redirect(http.StatusSeeOther, "/")
|
return kit.Redirect(http.StatusSeeOther, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleSignupIndex(kit *kit.Kit) error {
|
func HandleEmailVerify(kit *kit.Kit) error {
|
||||||
return kit.Render(SignupIndex(SignupIndexPageData{}))
|
tokenStr := kit.Request.URL.Query().Get("token")
|
||||||
}
|
if len(tokenStr) == 0 {
|
||||||
|
return kit.Render(EmailVerificationError("invalid verification token"))
|
||||||
|
}
|
||||||
|
|
||||||
func HandleSignupCreate(kit *kit.Kit) error {
|
token, err := jwt.ParseWithClaims(
|
||||||
var values SignupFormValues
|
tokenStr, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) {
|
||||||
errors, ok := v.Request(kit.Request, &values, signupSchema)
|
return []byte(os.Getenv("SUPERKIT_SECRET")), nil
|
||||||
if !ok {
|
}, jwt.WithLeeway(5*time.Second))
|
||||||
return kit.Render(SignupForm(values, errors))
|
|
||||||
}
|
|
||||||
if values.Password != values.PasswordConfirm {
|
|
||||||
errors.Add("passwordConfirm", "passwords do not match")
|
|
||||||
return kit.Render(SignupForm(values, errors))
|
|
||||||
}
|
|
||||||
user, err := createUserFromFormValues(values)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return kit.Render(ConfirmEmail(user.Email))
|
if !token.Valid {
|
||||||
|
return kit.Render(EmailVerificationError("invalid verification token"))
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*jwt.RegisteredClaims)
|
||||||
|
if !ok {
|
||||||
|
return kit.Render(EmailVerificationError("invalid verification token"))
|
||||||
|
}
|
||||||
|
if claims.ExpiresAt.Time.Before(time.Now()) {
|
||||||
|
return kit.Render(EmailVerificationError("Email verification token expired"))
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := strconv.Atoi(claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return kit.Render(EmailVerificationError("Email verification token expired"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
err = db.Query.NewSelect().
|
||||||
|
Model(&user).
|
||||||
|
Where("id = ?", userID).
|
||||||
|
Scan(kit.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.EmailVerifiedAt.After(time.Time{}) {
|
||||||
|
return kit.Render(EmailVerificationError("Email already verified"))
|
||||||
|
}
|
||||||
|
|
||||||
|
user.EmailVerifiedAt = time.Now()
|
||||||
|
_, err = db.Query.NewUpdate().
|
||||||
|
Model(&user).
|
||||||
|
WherePK().
|
||||||
|
Exec(kit.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return kit.Redirect(http.StatusSeeOther, "/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
func AuthenticateUser(kit *kit.Kit) (kit.Auth, error) {
|
func AuthenticateUser(kit *kit.Kit) (kit.Auth, error) {
|
||||||
|
@ -162,12 +182,8 @@ func AuthenticateUser(kit *kit.Kit) (kit.Auth, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return auth, nil
|
return auth, nil
|
||||||
}
|
}
|
||||||
// TODO: do we really need to check if the user is verified
|
|
||||||
// even if we check that already in the login process.
|
|
||||||
// if session.User.EmailVerifiedAt.Equal(time.Time{}) {
|
|
||||||
// return Auth{}, nil
|
|
||||||
// }
|
|
||||||
return Auth{
|
return Auth{
|
||||||
|
|
||||||
LoggedIn: true,
|
LoggedIn: true,
|
||||||
UserID: session.User.ID,
|
UserID: session.User.ID,
|
||||||
Email: session.User.Email,
|
Email: session.User.Email,
|
||||||
|
|
14
bootstrap/plugins/auth/email_verification_error_view.templ
Normal file
14
bootstrap/plugins/auth/email_verification_error_view.templ
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"AABBCCDD/app/views/layouts"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ EmailVerificationError(errorMessage string) {
|
||||||
|
@layouts.BaseLayout() {
|
||||||
|
<div class="h-screen flex flex-col justify-center items-center gap-4">
|
||||||
|
<div class="text-xl">{ errorMessage }</div>
|
||||||
|
<a href="/" class="underline text-sm">back to homepage</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"AABBCCDD/app/views/components"
|
"AABBCCDD/app/views/components"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthIndexPageData struct {
|
type LoginIndexPageData struct {
|
||||||
FormValues LoginFormValues
|
FormValues LoginFormValues
|
||||||
FormErrors v.Errors
|
FormErrors v.Errors
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,7 @@ type LoginFormValues struct {
|
||||||
Password string `form:"password"`
|
Password string `form:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
templ AuthIndex(data AuthIndexPageData) {
|
templ LoginIndex(data LoginIndexPageData) {
|
||||||
@layouts.BaseLayout() {
|
@layouts.BaseLayout() {
|
||||||
<div class="fixed top-6 right-6">
|
<div class="fixed top-6 right-6">
|
||||||
@components.ThemeSwitcher()
|
@components.ThemeSwitcher()
|
|
@ -11,14 +11,18 @@ func InitializeRoutes(router chi.Router) {
|
||||||
RedirectURL: "/login",
|
RedirectURL: "/login",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.Get("/email/verify", kit.Handler(HandleEmailVerify))
|
||||||
|
router.Post("/resend-email-verification", kit.Handler(HandleResendVerificationCode))
|
||||||
|
|
||||||
router.Group(func(auth chi.Router) {
|
router.Group(func(auth chi.Router) {
|
||||||
auth.Use(kit.WithAuthentication(authConfig, false))
|
auth.Use(kit.WithAuthentication(authConfig, false))
|
||||||
auth.Get("/login", kit.Handler(HandleAuthIndex))
|
auth.Get("/login", kit.Handler(HandleLoginIndex))
|
||||||
auth.Post("/login", kit.Handler(HandleAuthCreate))
|
auth.Post("/login", kit.Handler(HandleLoginCreate))
|
||||||
auth.Delete("/logout", kit.Handler(HandleAuthDelete))
|
auth.Delete("/logout", kit.Handler(HandleLoginDelete))
|
||||||
|
|
||||||
auth.Get("/signup", kit.Handler(HandleSignupIndex))
|
auth.Get("/signup", kit.Handler(HandleSignupIndex))
|
||||||
auth.Post("/signup", kit.Handler(HandleSignupCreate))
|
auth.Post("/signup", kit.Handler(HandleSignupCreate))
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.Group(func(auth chi.Router) {
|
router.Group(func(auth chi.Router) {
|
||||||
|
|
108
bootstrap/plugins/auth/signup_handler.go
Normal file
108
bootstrap/plugins/auth/signup_handler.go
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"AABBCCDD/app/db"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/anthdm/superkit/event"
|
||||||
|
"github.com/anthdm/superkit/kit"
|
||||||
|
v "github.com/anthdm/superkit/validate"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var signupSchema = v.Schema{
|
||||||
|
"email": v.Rules(v.Email),
|
||||||
|
"password": v.Rules(
|
||||||
|
v.ContainsSpecial,
|
||||||
|
v.ContainsUpper,
|
||||||
|
v.Min(7),
|
||||||
|
v.Max(50),
|
||||||
|
),
|
||||||
|
"firstName": v.Rules(v.Min(2), v.Max(50)),
|
||||||
|
"lastName": v.Rules(v.Min(2), v.Max(50)),
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleSignupIndex(kit *kit.Kit) error {
|
||||||
|
return kit.Render(SignupIndex(SignupIndexPageData{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleSignupCreate(kit *kit.Kit) error {
|
||||||
|
var values SignupFormValues
|
||||||
|
errors, ok := v.Request(kit.Request, &values, signupSchema)
|
||||||
|
if !ok {
|
||||||
|
return kit.Render(SignupForm(values, errors))
|
||||||
|
}
|
||||||
|
if values.Password != values.PasswordConfirm {
|
||||||
|
errors.Add("passwordConfirm", "passwords do not match")
|
||||||
|
return kit.Render(SignupForm(values, errors))
|
||||||
|
}
|
||||||
|
user, err := createUserFromFormValues(values)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token, err := createVerificationToken(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
event.Emit(UserSignupEvent, UserWithVerificationToken{
|
||||||
|
Token: token,
|
||||||
|
User: user,
|
||||||
|
})
|
||||||
|
return kit.Render(ConfirmEmail(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleResendVerificationCode(kit *kit.Kit) error {
|
||||||
|
idstr := kit.FormValue("userID")
|
||||||
|
id, err := strconv.Atoi(idstr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
err = db.Query.NewSelect().
|
||||||
|
Model(&user).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Scan(kit.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
return kit.Text(http.StatusOK, "An unexpected error occured")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.EmailVerifiedAt.After(time.Time{}) {
|
||||||
|
return kit.Text(http.StatusOK, "Email already verified!")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := createVerificationToken(id)
|
||||||
|
if err != nil {
|
||||||
|
return kit.Text(http.StatusOK, "An unexpected error occured")
|
||||||
|
}
|
||||||
|
|
||||||
|
event.Emit(ResendVerificationEvent, UserWithVerificationToken{
|
||||||
|
User: user,
|
||||||
|
Token: token,
|
||||||
|
})
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("A new verification token has been sent to %s", user.Email)
|
||||||
|
|
||||||
|
return kit.Text(http.StatusOK, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createVerificationToken(userID int) (string, error) {
|
||||||
|
expiryStr := kit.Getenv("SUPERKIT_AUTH_EMAIL_VERIFICATION_EXPIRY_IN_HOURS", "1")
|
||||||
|
expiry, err := strconv.Atoi(expiryStr)
|
||||||
|
if err != nil {
|
||||||
|
expiry = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := jwt.RegisteredClaims{
|
||||||
|
Subject: fmt.Sprint(userID),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * time.Duration(expiry))),
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|
||||||
|
return token.SignedString([]byte(os.Getenv("SUPERKIT_SECRET")))
|
||||||
|
}
|
|
@ -4,6 +4,8 @@ import (
|
||||||
v "github.com/anthdm/superkit/validate"
|
v "github.com/anthdm/superkit/validate"
|
||||||
"AABBCCDD/app/views/layouts"
|
"AABBCCDD/app/views/layouts"
|
||||||
"AABBCCDD/app/views/components"
|
"AABBCCDD/app/views/components"
|
||||||
|
|
||||||
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SignupIndexPageData struct {
|
type SignupIndexPageData struct {
|
||||||
|
@ -91,6 +93,10 @@ templ SignupForm(values SignupFormValues, errors v.Errors) {
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ConfirmEmail(email string) {
|
templ ConfirmEmail(user User) {
|
||||||
<div class="text-sm">An email confirmation link has been sent to: <span class="underline font-medium">{ email }</span></div>
|
<form hx-post="/resend-email-verification" class="flex flex-col gap-4 text-sm">
|
||||||
|
<input type="hidden" name="userID" value={ fmt.Sprint(user.ID) }/>
|
||||||
|
<div>An email confirmation link has been sent to: <span class="underline font-medium">{ user.Email }</span></div>
|
||||||
|
<div>Trouble receiving the verification code? <button class="underline font-medium cursor-pointer">Resend verification code</button></div>
|
||||||
|
</form>
|
||||||
}
|
}
|
|
@ -8,6 +8,19 @@ import (
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Event name constants
|
||||||
|
const (
|
||||||
|
UserSignupEvent = "auth.signup"
|
||||||
|
ResendVerificationEvent = "auth.resend.verification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserWithVerificationToken is a struct that will be sent over the
|
||||||
|
// auth.signup event. It holds the User struct and the Verification token string.
|
||||||
|
type UserWithVerificationToken struct {
|
||||||
|
User User
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
UserID int
|
UserID int
|
||||||
Email string
|
Email string
|
||||||
|
|
|
@ -878,6 +878,11 @@ body {
|
||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.text-xs {
|
.text-xs {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1rem;
|
line-height: 1rem;
|
||||||
|
@ -1005,14 +1010,14 @@ body {
|
||||||
margin-top: 5rem;
|
margin-top: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lg\:mt-40 {
|
|
||||||
margin-top: 10rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lg\:mt-32 {
|
.lg\:mt-32 {
|
||||||
margin-top: 8rem;
|
margin-top: 8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lg\:mt-40 {
|
||||||
|
margin-top: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
.lg\:text-4xl {
|
.lg\:text-4xl {
|
||||||
font-size: 2.25rem;
|
font-size: 2.25rem;
|
||||||
line-height: 2.5rem;
|
line-height: 2.5rem;
|
||||||
|
|
|
@ -68,6 +68,10 @@ func (kit *Kit) Redirect(status int, url string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (kit *Kit) FormValue(name string) string {
|
||||||
|
return kit.Request.PostFormValue(name)
|
||||||
|
}
|
||||||
|
|
||||||
func (kit *Kit) JSON(status int, v any) error {
|
func (kit *Kit) JSON(status int, v any) error {
|
||||||
kit.Response.WriteHeader(status)
|
kit.Response.WriteHeader(status)
|
||||||
kit.Response.Header().Set("Content-Type", "application/json")
|
kit.Response.Header().Set("Content-Type", "application/json")
|
||||||
|
|
Loading…
Reference in a new issue