diff --git a/db/auth.go b/db/auth.go index 793f8a6..1052969 100644 --- a/db/auth.go +++ b/db/auth.go @@ -38,19 +38,19 @@ func NewUser(id uuid.UUID, email string, emailVerified bool, emailVerifiedAt tim } } -type Auth interface { +type DbAuth interface { GetUser(email string) (*User, error) } -type AuthSqlite struct { +type DbAuthSqlite struct { db *sql.DB } -func NewAuthSqlite(db *sql.DB) *AuthSqlite { - return &AuthSqlite{db: db} +func NewDbAuthSqlite(db *sql.DB) *DbAuthSqlite { + return &DbAuthSqlite{db: db} } -func (a AuthSqlite) GetUser(email string) (*User, error) { +func (db DbAuthSqlite) GetUser(email string) (*User, error) { var ( userId uuid.UUID emailVerified bool @@ -61,7 +61,7 @@ func (a AuthSqlite) GetUser(email string) (*User, error) { createdAt time.Time ) - err := a.db.QueryRow(` + err := db.db.QueryRow(` SELECT user_uuid, email_verified, email_verified_at, password, salt, created_at FROM user WHERE email = ?`, email).Scan(&userId, &emailVerified, &emailVerifiedAt, &password, &salt, &createdAt) diff --git a/db/auth_test.go b/db/auth_test.go index 3dcaa45..3084f63 100644 --- a/db/auth_test.go +++ b/db/auth_test.go @@ -29,7 +29,7 @@ func TestGetUser(t *testing.T) { db := setupDb(t) defer db.Close() - underTest := AuthSqlite{db: db} + underTest := DbAuthSqlite{db: db} _, err := underTest.GetUser("someNonExistentEmail") if err != UserNotFound { @@ -42,7 +42,7 @@ func TestGetUser(t *testing.T) { db := setupDb(t) defer db.Close() - underTest := AuthSqlite{db: db} + underTest := DbAuthSqlite{db: db} verifiedAt := time.Date(2020, 1, 5, 13, 0, 0, 0, time.UTC) createAt := time.Date(2020, 1, 5, 12, 0, 0, 0, time.UTC) diff --git a/handler/auth.go b/handler/auth.go index 5bb74f3..e8b3c69 100644 --- a/handler/auth.go +++ b/handler/auth.go @@ -1,61 +1,88 @@ package handler import ( + "me-fit/db" "me-fit/service" "me-fit/utils" + "time" "database/sql" "net/http" ) -type AuthHandler struct { +type HandlerAuth interface { + handle(router *http.ServeMux) +} + +type HandlerAuthImpl struct { db *sql.DB - service *service.AuthService + service service.ServiceAuth } -func handleAuth(db *sql.DB, router *http.ServeMux) { - a := AuthHandler{ +func NewHandlerAuth(db *sql.DB, service service.ServiceAuth) HandlerAuth { + return HandlerAuthImpl{ db: db, - service: service.NewAuthService(db), + service: service, } - // Don't use auth middleware for these routes, as it makes redirecting very difficult, if the mail is not yet verified - router.Handle("/auth/signin", service.HandleSignInPage(db)) - router.Handle("/auth/signup", service.HandleSignUpPage(db)) - router.Handle("/auth/verify", service.HandleSignUpVerifyPage(db)) // Hint for the user to verify their email - router.Handle("/auth/delete-account", service.HandleDeleteAccountPage(db)) - router.Handle("/auth/verify-email", service.HandleSignUpVerifyResponsePage(db)) // The link contained in the email - router.Handle("/auth/change-password", service.HandleChangePasswordPage(db)) - router.Handle("/auth/reset-password", service.HandleResetPasswordPage(db)) - router.Handle("/api/auth/signup", service.HandleSignUpComp(db)) - router.Handle("/api/auth/signin", a.handleSignIn()) - router.Handle("/api/auth/signout", service.HandleSignOutComp(db)) - router.Handle("/api/auth/delete-account", service.HandleDeleteAccountComp(db)) - router.Handle("/api/auth/verify-resend", service.HandleVerifyResendComp(db)) - router.Handle("/api/auth/change-password", service.HandleChangePasswordComp(db)) - router.Handle("/api/auth/reset-password", service.HandleResetPasswordComp(db)) - router.Handle("/api/auth/reset-password-actual", service.HandleActualResetPasswordComp(db)) } -func (a AuthHandler) handleSignIn() http.HandlerFunc { +func (handler HandlerAuthImpl) handle(router *http.ServeMux) { + // Don't use auth middleware for these routes, as it makes redirecting very difficult, if the mail is not yet verified + router.Handle("/auth/signin", service.HandleSignInPage(handler.db)) + router.Handle("/auth/signup", service.HandleSignUpPage(handler.db)) + router.Handle("/auth/verify", service.HandleSignUpVerifyPage(handler.db)) // Hint for the user to verify their email + router.Handle("/auth/delete-account", service.HandleDeleteAccountPage(handler.db)) + router.Handle("/auth/verify-email", service.HandleSignUpVerifyResponsePage(handler.db)) // The link contained in the email + router.Handle("/auth/change-password", service.HandleChangePasswordPage(handler.db)) + router.Handle("/auth/reset-password", service.HandleResetPasswordPage(handler.db)) + router.Handle("/api/auth/signup", service.HandleSignUpComp(handler.db)) + router.Handle("/api/auth/signin", handler.handleSignIn()) + router.Handle("/api/auth/signout", service.HandleSignOutComp(handler.db)) + router.Handle("/api/auth/delete-account", service.HandleDeleteAccountComp(handler.db)) + router.Handle("/api/auth/verify-resend", service.HandleVerifyResendComp(handler.db)) + router.Handle("/api/auth/change-password", service.HandleChangePasswordComp(handler.db)) + router.Handle("/api/auth/reset-password", service.HandleResetPasswordComp(handler.db)) + router.Handle("/api/auth/reset-password-actual", service.HandleActualResetPasswordComp(handler.db)) +} + +var ( + securityWaitDuration = 250 * time.Millisecond +) + +func (handler HandlerAuthImpl) handleSignIn() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var email = r.FormValue("email") - var password = r.FormValue("password") + user, err := utils.WaitMinimumTime(securityWaitDuration, func() (*db.User, error) { + var email = r.FormValue("email") + var password = r.FormValue("password") - user := a.service.SignIn(email, password) - - if user != nil { - result := service.TryCreateSessionAndSetCookie(r, w, a.db, user.Id) - if !result { - return + user, err := handler.service.SignIn(email, password) + if err != nil { + return nil, err } - if !user.EmailVerified { - utils.DoRedirect(w, r, "/auth/verify") + err = service.TryCreateSessionAndSetCookie(r, w, handler.db, user.Id) + if err != nil { + return nil, err + } + + return user, nil + }) + + if err != nil { + if err == service.InvaidEmailOrPassword { + utils.TriggerToast(w, r, "error", "Invalid email or password") + http.Error(w, "Invalid email or password", http.StatusUnauthorized) } else { - utils.DoRedirect(w, r, "/") + utils.LogError("Error signing in", err) + http.Error(w, "An error occurred", http.StatusInternalServerError) } + return + } + + if user.EmailVerified { + utils.DoRedirect(w, r, "/") } else { - http.Error(w, "Invalid email or password", http.StatusUnauthorized) + utils.DoRedirect(w, r, "/auth/verify") } } } diff --git a/handler/default.go b/handler/default.go index e877d59..34a1cf7 100644 --- a/handler/default.go +++ b/handler/default.go @@ -13,12 +13,14 @@ func GetHandler(db *sql.DB) http.Handler { router.HandleFunc("/", service.HandleIndexAnd404(db)) + handlerAuth := NewHandlerAuth(db, service.NewServiceAuthImpl(db)) + // Serve static files (CSS, JS and images) router.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static/")))) handleWorkout(db, router) - handleAuth(db, router) + handlerAuth.handle(router) return middleware.Logging(middleware.EnableCors(router)) } diff --git a/service/auth.go b/service/auth.go index c03b4f8..4d805ee 100644 --- a/service/auth.go +++ b/service/auth.go @@ -11,7 +11,6 @@ import ( "net/mail" "net/url" "strings" - "time" "me-fit/db" "me-fit/template" @@ -25,44 +24,42 @@ import ( "golang.org/x/crypto/argon2" ) -type AuthService struct { - db db.Auth +var ( + InvaidEmailOrPassword = errors.New("Invalid email or password") +) + +type ServiceAuth interface { + SignIn(email string, password string) (*db.User, error) } -func NewAuthService(d *sql.DB) *AuthService { - return &AuthService{ - db: db.NewAuthSqlite(d), +type ServiceAuthImpl struct { + dbAuth db.DbAuth +} + +func NewServiceAuthImpl(d *sql.DB) *ServiceAuthImpl { + return &ServiceAuthImpl{ + dbAuth: db.NewDbAuthSqlite(d), } } -func (a AuthService) SignIn(email string, password string) *db.User { +func (service ServiceAuthImpl) SignIn(email string, password string) (*db.User, error) { - var result bool = true - start := time.Now() - - user, err := a.db.GetUser(email) + user, err := service.dbAuth.GetUser(email) if err != nil { - result = false - } - - if result { - new_hash := getHashPassword(password, user.Salt) - - if subtle.ConstantTimeCompare(new_hash, user.Password) == 0 { - result = false + if errors.Is(err, db.UserNotFound) { + return nil, InvaidEmailOrPassword + } else { + return nil, err } } - duration := time.Since(start) - timeToWait := 100 - duration.Milliseconds() - // It is important to sleep for a while to prevent timing attacks - // If the email is correct, the server will calculate the hash, which will take some time - // This way an attacker could guess emails when comparing the response time - // Because of that, we cant use WriteHeader in the middle of the function. We have to wait until the end - // Unfortunatly this makes the code harder to read - time.Sleep(time.Duration(timeToWait) * time.Millisecond) + hash := getHashPassword(password, user.Salt) - return user + if subtle.ConstantTimeCompare(hash, user.Password) == 0 { + return nil, InvaidEmailOrPassword + } + + return user, nil } func HandleSignInPage(db *sql.DB) http.HandlerFunc { @@ -286,8 +283,8 @@ func HandleSignUpComp(db *sql.DB) http.HandlerFunc { return } - result := TryCreateSessionAndSetCookie(r, w, db, userId) - if !result { + err = TryCreateSessionAndSetCookie(r, w, db, userId) + if err != nil { return } @@ -603,25 +600,24 @@ func sendVerificationEmail(db *sql.DB, userId string, email string) { utils.SendMail(email, "Welcome to ME-FIT", w.String()) } -func TryCreateSessionAndSetCookie(r *http.Request, w http.ResponseWriter, db *sql.DB, user_uuid uuid.UUID) bool { +func TryCreateSessionAndSetCookie(r *http.Request, w http.ResponseWriter, db *sql.DB, user_uuid uuid.UUID) error { sessionId, err := utils.RandomToken() if err != nil { utils.LogError("Could not generate session ID", err) - auth.Error("Internal Server Error").Render(r.Context(), w) - return false + return types.InternalServerError } // Delete old inactive sessions _, err = db.Exec("DELETE FROM session WHERE created_at < datetime('now','-8 hours') AND user_uuid = ?", user_uuid) if err != nil { utils.LogError("Could not delete old sessions", err) + return types.InternalServerError } _, err = db.Exec("INSERT INTO session (session_id, user_uuid, created_at) VALUES (?, ?, datetime())", sessionId, user_uuid) if err != nil { utils.LogError("Could not insert session", err) - auth.Error("Internal Server Error").Render(r.Context(), w) - return false + return types.InternalServerError } cookie := http.Cookie{ @@ -635,7 +631,7 @@ func TryCreateSessionAndSetCookie(r *http.Request, w http.ResponseWriter, db *sq } http.SetCookie(w, &cookie) - return true + return nil } func getHashPassword(password string, salt []byte) []byte { diff --git a/utils/http.go b/utils/http.go index 406d0de..b6e29a4 100644 --- a/utils/http.go +++ b/utils/http.go @@ -90,6 +90,13 @@ func GetUserFromSession(db *sql.DB, r *http.Request) *types.User { } +func WaitMinimumTime[T interface{}](waitTime time.Duration, function func() (T, error)) (T, error) { + start := time.Now() + result, err := function() + time.Sleep(waitTime - time.Since(start)) + return result, err +} + func getSessionID(r *http.Request) string { for _, c := range r.Cookies() { if c.Name == "id" {