451 lines
10 KiB
Go
451 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/mail"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"me-fit/db"
|
|
"me-fit/template/auth"
|
|
tempMail "me-fit/template/mail"
|
|
"me-fit/types"
|
|
"me-fit/utils"
|
|
|
|
"github.com/a-h/templ"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/argon2"
|
|
)
|
|
|
|
var (
|
|
ErrInvaidCredentials = errors.New("invalid email or password")
|
|
ErrInvalidPassword = errors.New("password needs to be 8 characters long, contain at least one number, one special, one uppercase and one lowercase character")
|
|
ErrInvalidEmail = errors.New("invalid email")
|
|
ErrAccountExists = errors.New("account already exists")
|
|
ErrSessionIdInvalid = errors.New("session ID is invalid")
|
|
)
|
|
|
|
type User struct {
|
|
Id uuid.UUID
|
|
Email string
|
|
EmailVerified bool
|
|
}
|
|
|
|
func NewUser(user *db.User) *User {
|
|
return &User{
|
|
Id: user.Id,
|
|
Email: user.Email,
|
|
EmailVerified: user.EmailVerified,
|
|
}
|
|
}
|
|
|
|
type Session struct {
|
|
Id string
|
|
CreatedAt time.Time
|
|
User *User
|
|
}
|
|
|
|
func NewSession(session *db.Session, user *User) *Session {
|
|
return &Session{
|
|
Id: session.Id,
|
|
CreatedAt: session.CreatedAt,
|
|
User: user,
|
|
}
|
|
}
|
|
|
|
type AuthService interface {
|
|
SignIn(email string, password string) (*Session, error)
|
|
SignUp(email string, password string) (*User, error)
|
|
SendVerificationMail(userId uuid.UUID, email string)
|
|
SignOut(sessionId string) error
|
|
DeleteAccount(user *User) error
|
|
ChangePassword(user *User, currPass, newPass string) error
|
|
|
|
GetUserFromSessionId(sessionId string) (*User, error)
|
|
}
|
|
|
|
type AuthServiceImpl struct {
|
|
dbAuth db.AuthDb
|
|
randomGenerator RandomService
|
|
clock ClockService
|
|
mailService MailService
|
|
serverSettings *types.ServerSettings
|
|
}
|
|
|
|
func NewAuthServiceImpl(dbAuth db.AuthDb, randomGenerator RandomService, clock ClockService, mailService MailService, serverSettings *types.ServerSettings) *AuthServiceImpl {
|
|
return &AuthServiceImpl{
|
|
dbAuth: dbAuth,
|
|
randomGenerator: randomGenerator,
|
|
clock: clock,
|
|
mailService: mailService,
|
|
serverSettings: serverSettings,
|
|
}
|
|
}
|
|
|
|
func (service AuthServiceImpl) SignIn(email string, password string) (*Session, error) {
|
|
user, err := service.dbAuth.GetUser(email)
|
|
if err != nil {
|
|
if errors.Is(err, db.ErrUserNotFound) {
|
|
return nil, ErrInvaidCredentials
|
|
} else {
|
|
return nil, types.ErrInternal
|
|
}
|
|
}
|
|
|
|
hash := GetHashPassword(password, user.Salt)
|
|
|
|
if subtle.ConstantTimeCompare(hash, user.Password) == 0 {
|
|
return nil, ErrInvaidCredentials
|
|
}
|
|
|
|
session, err := service.createSession(user.Id)
|
|
if err != nil {
|
|
return nil, types.ErrInternal
|
|
}
|
|
|
|
return NewSession(session, NewUser(user)), nil
|
|
}
|
|
|
|
func (service AuthServiceImpl) createSession(userId uuid.UUID) (*db.Session, error) {
|
|
sessionId, err := service.randomGenerator.String(32)
|
|
if err != nil {
|
|
return nil, types.ErrInternal
|
|
}
|
|
|
|
err = service.dbAuth.DeleteOldSessions(userId)
|
|
if err != nil {
|
|
return nil, types.ErrInternal
|
|
}
|
|
|
|
session := db.NewSession(sessionId, userId, service.clock.Now())
|
|
|
|
err = service.dbAuth.InsertSession(session)
|
|
if err != nil {
|
|
return nil, types.ErrInternal
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
func (service AuthServiceImpl) SignUp(email string, password string) (*User, error) {
|
|
_, err := mail.ParseAddress(email)
|
|
if err != nil {
|
|
return nil, ErrInvalidEmail
|
|
}
|
|
|
|
if !isPasswordValid(password) {
|
|
return nil, ErrInvalidPassword
|
|
}
|
|
|
|
userId, err := service.randomGenerator.UUID()
|
|
if err != nil {
|
|
return nil, types.ErrInternal
|
|
}
|
|
|
|
salt, err := service.randomGenerator.Bytes(16)
|
|
if err != nil {
|
|
return nil, types.ErrInternal
|
|
}
|
|
|
|
hash := GetHashPassword(password, salt)
|
|
|
|
dbUser := db.NewUser(userId, email, false, nil, false, hash, salt, service.clock.Now())
|
|
|
|
err = service.dbAuth.InsertUser(dbUser)
|
|
if err != nil {
|
|
if err == db.ErrUserExists {
|
|
return nil, ErrAccountExists
|
|
} else {
|
|
return nil, types.ErrInternal
|
|
}
|
|
}
|
|
|
|
return NewUser(dbUser), nil
|
|
}
|
|
|
|
func (service AuthServiceImpl) SendVerificationMail(userId uuid.UUID, email string) {
|
|
var token string
|
|
|
|
token, err := service.dbAuth.GetEmailVerificationToken(userId)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
if token == "" {
|
|
token, err := service.randomGenerator.String(32)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = service.dbAuth.InsertEmailVerificationToken(userId, token)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
var w strings.Builder
|
|
err = tempMail.Register(service.serverSettings.BaseUrl, token).Render(context.Background(), &w)
|
|
if err != nil {
|
|
utils.LogError("Could not render welcome email", err)
|
|
return
|
|
}
|
|
|
|
service.mailService.SendMail(email, "Welcome to ME-FIT", w.String())
|
|
}
|
|
|
|
func (service AuthServiceImpl) SignOut(sessionId string) error {
|
|
|
|
return service.dbAuth.DeleteSession(sessionId)
|
|
}
|
|
|
|
func (service AuthServiceImpl) GetUserFromSessionId(sessionId string) (*User, error) {
|
|
if sessionId == "" {
|
|
return nil, ErrSessionIdInvalid
|
|
}
|
|
|
|
session, err := service.dbAuth.GetSession(sessionId)
|
|
if err != nil {
|
|
return nil, types.ErrInternal
|
|
}
|
|
|
|
user, err := service.dbAuth.GetUserById(session.UserId)
|
|
if err != nil {
|
|
return nil, types.ErrInternal
|
|
}
|
|
|
|
if session.CreatedAt.Add(time.Duration(8 * time.Hour)).Before(service.clock.Now()) {
|
|
return nil, nil
|
|
} else {
|
|
return NewUser(user), nil
|
|
}
|
|
}
|
|
|
|
// TODO
|
|
|
|
func HandleSignUpVerifyResponsePage(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
token := r.URL.Query().Get("token")
|
|
|
|
if token == "" {
|
|
utils.DoRedirect(w, r, "/auth/verify")
|
|
return
|
|
}
|
|
|
|
result, err := db.Exec(`
|
|
UPDATE user
|
|
SET email_verified = true, email_verified_at = datetime()
|
|
WHERE user_uuid = (
|
|
SELECT user_uuid
|
|
FROM user_token
|
|
WHERE type = "email_verify"
|
|
AND token = ?
|
|
);
|
|
`, token)
|
|
|
|
if err != nil {
|
|
utils.LogError("Could not update user on verify response", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
i, err := result.RowsAffected()
|
|
if err != nil {
|
|
utils.LogError("Could not get rows affected on verify response", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if i == 0 {
|
|
utils.DoRedirect(w, r, "/")
|
|
} else {
|
|
utils.DoRedirect(w, r, "/auth/signin")
|
|
}
|
|
}
|
|
}
|
|
|
|
func UserInfoComp(user *User) templ.Component {
|
|
|
|
if user != nil {
|
|
return auth.UserComp(user.Email)
|
|
} else {
|
|
return auth.UserComp("")
|
|
}
|
|
}
|
|
|
|
func (service AuthServiceImpl) DeleteAccount(user *User) error {
|
|
|
|
err := service.dbAuth.DeleteUser(user.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
go service.mailService.SendMail(user.Email, "Account deleted", "Your account has been deleted")
|
|
|
|
return nil
|
|
}
|
|
|
|
func (service AuthServiceImpl) ChangePassword(user *User, currPass, newPass string) error {
|
|
|
|
if !isPasswordValid(newPass) {
|
|
return ErrInvalidPassword
|
|
}
|
|
|
|
if currPass == newPass {
|
|
return ErrInvalidPassword
|
|
}
|
|
|
|
_, err := service.SignIn(user.Email, currPass)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
userDb, err := service.dbAuth.GetUserById(user.Id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
newHash := GetHashPassword(newPass, userDb.Salt)
|
|
|
|
err = service.dbAuth.UpdateUserPassword(user.Id, newHash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func HandleActualResetPasswordComp(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
pageUrl, err := url.Parse(r.Header.Get("HX-Current-URL"))
|
|
if err != nil {
|
|
utils.LogError("Could not get current URL", err)
|
|
utils.TriggerToast(w, r, "error", "Internal Server Error")
|
|
return
|
|
}
|
|
|
|
token := pageUrl.Query().Get("token")
|
|
if token == "" {
|
|
utils.TriggerToast(w, r, "error", "No token")
|
|
return
|
|
}
|
|
|
|
newPass := r.FormValue("new-password")
|
|
|
|
if !isPasswordValid(newPass) {
|
|
utils.TriggerToast(w, r, "error", ErrInvalidPassword.Error())
|
|
return
|
|
}
|
|
|
|
var (
|
|
userId uuid.UUID
|
|
salt []byte
|
|
)
|
|
|
|
err = db.QueryRow(`
|
|
SELECT u.user_uuid, salt
|
|
FROM user_token t
|
|
INNER JOIN user u ON t.user_uuid = u.user_uuid
|
|
WHERE t.token = ?
|
|
AND t.type = 'password_reset'
|
|
AND t.expires_at > datetime()
|
|
`, token).Scan(&userId, &salt)
|
|
if err != nil {
|
|
slog.Warn("Could not get user from token: " + err.Error())
|
|
utils.TriggerToast(w, r, "error", "Invalid token")
|
|
return
|
|
}
|
|
|
|
_, err = db.Exec("DELETE FROM user_token WHERE token = ? AND type = 'password_reset'", token)
|
|
if err != nil {
|
|
utils.LogError("Could not delete token", err)
|
|
utils.TriggerToast(w, r, "error", "Internal Server Error")
|
|
return
|
|
}
|
|
|
|
passHash := GetHashPassword(newPass, salt)
|
|
|
|
_, err = db.Exec("UPDATE user SET password = ? WHERE user_uuid = ?", passHash, userId)
|
|
if err != nil {
|
|
utils.LogError("Could not update password", err)
|
|
utils.TriggerToast(w, r, "error", "Internal Server Error")
|
|
return
|
|
}
|
|
|
|
utils.TriggerToast(w, r, "success", "Password changed")
|
|
}
|
|
}
|
|
|
|
func HandleResetPasswordComp(db *sql.DB, serverSettings *types.ServerSettings) http.HandlerFunc {
|
|
mailService := NewMailServiceImpl(serverSettings)
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
email := r.FormValue("email")
|
|
if email == "" {
|
|
utils.TriggerToast(w, r, "error", "Please enter an email")
|
|
return
|
|
}
|
|
|
|
token, err := NewRandomServiceImpl().String(32)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
res, err := db.Exec(`
|
|
INSERT INTO user_token (user_uuid, type, token, created_at, expires_at)
|
|
SELECT user_uuid, 'password_reset', ?, datetime(), datetime('now', '+15 minute')
|
|
FROM user
|
|
WHERE email = ?
|
|
`, token, email)
|
|
if err != nil {
|
|
utils.LogError("Could not insert token", err)
|
|
utils.TriggerToast(w, r, "error", "Internal Server Error")
|
|
return
|
|
}
|
|
|
|
i, err := res.RowsAffected()
|
|
if err != nil {
|
|
utils.LogError("Could not get rows affected", err)
|
|
utils.TriggerToast(w, r, "error", "Internal Server Error")
|
|
return
|
|
}
|
|
|
|
if i != 0 {
|
|
var mail strings.Builder
|
|
err = tempMail.ResetPassword(serverSettings.BaseUrl, token).Render(context.Background(), &mail)
|
|
if err != nil {
|
|
utils.LogError("Could not render reset password email", err)
|
|
utils.TriggerToast(w, r, "error", "Internal Server Error")
|
|
return
|
|
}
|
|
mailService.SendMail(email, "Reset Password", mail.String())
|
|
}
|
|
|
|
utils.TriggerToast(w, r, "info", "If the email exists, an email has been sent")
|
|
}
|
|
}
|
|
|
|
func GetHashPassword(password string, salt []byte) []byte {
|
|
return argon2.IDKey([]byte(password), salt, 1, 64*1024, 1, 16)
|
|
}
|
|
|
|
func isPasswordValid(password string) bool {
|
|
|
|
if len(password) < 8 ||
|
|
!strings.ContainsAny(password, "0123456789") ||
|
|
!strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") ||
|
|
!strings.ContainsAny(password, "abcdefghijklmnopqrstuvwxyz") ||
|
|
!strings.ContainsAny(password, "!@#$%^&*()_+-=[]{}\\|;:'\",.<>/?") {
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
}
|