package auth import ( "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "io" "strconv" "strings" "time" "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/jobs" "git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/utils" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/argon2" "golang.org/x/crypto/pbkdf2" ) type HashAlgorithm string const ( Django_PBKDF2SHA256 HashAlgorithm = "pbkdf2_sha256" Argon2id HashAlgorithm = "argon2id" ) const saltLength = 16 const keyLength = 64 type HashedPassword struct { Algorithm HashAlgorithm AlgoConfig string // arbitrary info describing the hash parameters (e.g. work factor) // To make it easier to handle varying implementations and encodings, // these fields will always store a form of the data that can be directly // stored in the database (usually base64-encoded or whatever). Salt string Hash string } func ParsePasswordString(s string) (HashedPassword, error) { pieces := strings.SplitN(s, "$", 4) if len(pieces) < 4 { return HashedPassword{}, oops.New(nil, "unrecognized password string format") } return HashedPassword{ Algorithm: HashAlgorithm(pieces[0]), AlgoConfig: pieces[1], Salt: pieces[2], Hash: pieces[3], }, nil } func (p HashedPassword) String() string { return fmt.Sprintf("%s$%s$%s$%s", p.Algorithm, p.AlgoConfig, p.Salt, p.Hash) } func (p HashedPassword) IsOutdated() bool { return p.Algorithm != Argon2id } type Argon2idConfig struct { Time uint32 Memory uint32 Threads uint8 KeyLength uint32 } func ParseArgon2idConfig(cfg string) (Argon2idConfig, error) { parts := strings.Split(cfg, ",") t64, err := strconv.ParseUint(parts[0][2:], 10, 32) if err != nil { return Argon2idConfig{}, oops.New(err, "failed to parse time in Argon2id config") } m64, err := strconv.ParseUint(parts[1][2:], 10, 32) if err != nil { return Argon2idConfig{}, oops.New(err, "failed to parse memory in Argon2id config") } p64, err := strconv.ParseUint(parts[2][2:], 10, 8) if err != nil { return Argon2idConfig{}, oops.New(err, "failed to parse threads in Argon2id config") } l64, err := strconv.ParseUint(parts[3][2:], 10, 32) if err != nil { return Argon2idConfig{}, oops.New(err, "failed to parse key length in Argon2id config") } return Argon2idConfig{ Time: uint32(t64), Memory: uint32(m64), Threads: uint8(p64), KeyLength: uint32(l64), }, nil } func (c Argon2idConfig) String() string { return fmt.Sprintf("t=%v,m=%v,p=%v,l=%v", c.Time, c.Memory, c.Threads, c.KeyLength) } func CheckPassword(password string, hashedPassword HashedPassword) (bool, error) { switch hashedPassword.Algorithm { case Argon2id: cfg, err := ParseArgon2idConfig(hashedPassword.AlgoConfig) if err != nil { return false, err } salt, err := base64.StdEncoding.DecodeString(hashedPassword.Salt) if err != nil { return false, oops.New(err, "failed to decode salt") } newHash := argon2.IDKey([]byte(password), []byte(salt), cfg.Time, cfg.Memory, cfg.Threads, cfg.KeyLength) newHashEnc := base64.StdEncoding.EncodeToString(newHash) return bytes.Equal([]byte(newHashEnc), []byte(hashedPassword.Hash)), nil case Django_PBKDF2SHA256: decoded, err := base64.StdEncoding.DecodeString(hashedPassword.Hash) if err != nil { return false, oops.New(nil, "failed to get key length of hashed password") } iterations, err := strconv.Atoi(hashedPassword.AlgoConfig) if err != nil { return false, oops.New(nil, "failed to get PBKDF2 iterations") } newHash := pbkdf2.Key( []byte(password), []byte(hashedPassword.Salt), iterations, len(decoded), sha256.New, ) newHashEncoded := base64.StdEncoding.EncodeToString(newHash) return bytes.Equal([]byte(newHashEncoded), []byte(hashedPassword.Hash)), nil default: return false, oops.New(nil, "unrecognized password hash algorithm: %s", hashedPassword.Algorithm) } } func HashPassword(password string) HashedPassword { // Follows the OWASP recommendations as of March 2021. // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html salt := make([]byte, saltLength) io.ReadFull(rand.Reader, salt) saltEnc := base64.StdEncoding.EncodeToString(salt) cfg := Argon2idConfig{ Time: 1, Memory: 40 * 1024, // this is in KiB for some reason Threads: 1, KeyLength: keyLength, } key := argon2.IDKey([]byte(password), salt, cfg.Time, cfg.Memory, cfg.Threads, cfg.KeyLength) keyEnc := base64.StdEncoding.EncodeToString(key) return HashedPassword{ Algorithm: Argon2id, AlgoConfig: cfg.String(), Salt: saltEnc, Hash: keyEnc, } } var ErrUserDoesNotExist = errors.New("user does not exist") func UpdatePassword(ctx context.Context, conn db.ConnOrTx, username string, hp HashedPassword) error { tag, err := conn.Exec(ctx, "UPDATE hmn_user SET password = $1 WHERE username = $2", hp.String(), username) if err != nil { return oops.New(err, "failed to update password") } else if tag.RowsAffected() < 1 { return ErrUserDoesNotExist } return nil } func SetPassword(ctx context.Context, conn db.ConnOrTx, username string, password string) error { hp := HashPassword(password) return UpdatePassword(ctx, conn, username, hp) } func DeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) (int64, error) { tag, err := conn.Exec(ctx, ` DELETE FROM hmn_user WHERE status = $1 AND (SELECT COUNT(*) as ct FROM one_time_token AS ott WHERE ott.owner_id = hmn_user.id AND ott.expires < $2 AND ott.token_type = $3) > 0; `, models.UserStatusInactive, time.Now(), models.TokenTypeRegistration, ) if err != nil { return 0, oops.New(err, "failed to delete inactive users") } return tag.RowsAffected(), nil } func DeleteExpiredPasswordResets(ctx context.Context, conn *pgxpool.Pool) (int64, error) { tag, err := conn.Exec(ctx, ` DELETE FROM one_time_token WHERE token_type = $1 AND expires < $2 `, models.TokenTypePasswordReset, time.Now(), ) if err != nil { return 0, oops.New(err, "failed to delete expired password resets") } return tag.RowsAffected(), nil } func PeriodicallyDeleteInactiveUsers(ctx context.Context, conn *pgxpool.Pool) jobs.Job { job := jobs.New() go func() { defer job.Done() t := time.NewTicker(1 * time.Hour) for { select { case <-t.C: err := func() (err error) { defer utils.RecoverPanicAsError(&err) n, err := DeleteInactiveUsers(ctx, conn) if err == nil { if n > 0 { logging.Info().Int64("num deleted users", n).Msg("Deleted inactive users") } } else { logging.Error().Err(err).Msg("Failed to delete inactive users") } n, err = DeleteExpiredPasswordResets(ctx, conn) if err == nil { if n > 0 { logging.Info().Int64("num deleted password resets", n).Msg("Deleted expired password resets") } } else { logging.Error().Err(err).Msg("Failed to delete expired password resets") } return nil }() if err != nil { logging.Error().Err(err).Msg("Panicked in PeriodicallyDeleteInactiveUsers") } case <-ctx.Done(): return } } }() return job }