blog/models/users/users.go

203 lines
4.2 KiB
Go

package users
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/kirsle/blog/jsondb"
"golang.org/x/crypto/bcrypt"
)
// DB is a reference to the parent app's JsonDB object.
var DB *jsondb.DB
// HashCost is the cost value given to bcrypt to hash passwords.
// TODO: make configurable from main package
var HashCost = 14
// User holds information about a user account.
type User struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Admin bool `json:"admin"`
Name string `json:"name"`
Email string `json:"email"`
IsAuthenticated bool `json:"-"`
// Whether the user was loaded in read-only mode (no password), so they
// can't be saved without a password.
readonly bool
}
// ByName model maps usernames to their IDs.
type ByName struct {
ID int `json:"id"`
}
// Create a new user.
func Create(u *User) error {
// Sanity checks.
u.Username = Normalize(u.Username)
if len(u.Username) == 0 {
return errors.New("username is required")
} else if len(u.Password) == 0 {
return errors.New("password is required")
}
// Make sure the username is available.
if UsernameExists(u.Username) {
return errors.New("that username already exists")
}
// Assign the next ID.
u.ID = nextID()
// Hash the password.
u.SetPassword(u.Password)
// TODO: check existing
return u.Save()
}
// DeletedUser returns a User object to represent a deleted (non-existing) user.
func DeletedUser() *User {
return &User{
Username: "[deleted]",
}
}
// CheckAuth tests a login with a username and password.
func CheckAuth(username, password string) (*User, error) {
username = Normalize(username)
// Look up the user by username.
u, err := LoadUsername(username)
if err != nil {
return nil, err
}
// Check the password.
err = bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
if err != nil {
return nil, err
}
return u, nil
}
// SetPassword sets a user's password by bcrypt hashing it. After this function,
// u.Password will contain the bcrypt hash.
func (u *User) SetPassword(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), HashCost)
if err != nil {
return err
}
u.Password = string(hash)
return nil
}
// UsernameExists checks if a username is taken.
func UsernameExists(username string) bool {
username = Normalize(username)
return DB.Exists("users/by-name/" + username)
}
// LoadUsername loads a user by username.
func LoadUsername(username string) (*User, error) {
username = Normalize(username)
u := &User{}
// Look up the user ID by name.
name := ByName{}
err := DB.Get("users/by-name/"+username, &name)
if err != nil {
return u, fmt.Errorf("failed to look up user ID for username %s: %v", username, err)
}
// And load that user.
return Load(name.ID)
}
// Load a user by their ID number.
func Load(id int) (*User, error) {
u := &User{}
err := DB.Get(fmt.Sprintf("users/by-id/%d", id), &u)
return u, err
}
// LoadReadonly loads a user for read-only use, so the Password is masked.
func LoadReadonly(id int) (*User, error) {
u, err := Load(id)
u.Password = ""
u.readonly = true
return u, err
}
// Save the user.
func (u *User) Save() error {
if u.readonly {
return errors.New("user is read-only")
}
// Sanity check that we have an ID.
if u.ID == 0 {
return errors.New("can't save a user with no ID")
}
// Save the main DB file.
err := DB.Commit(u.key(), u)
if err != nil {
return err
}
// The username to ID mapping.
err = DB.Commit(u.nameKey(), ByName{u.ID})
if err != nil {
return err
}
return nil
}
// Get the next user ID number.
func nextID() int {
// Highest ID seen so far.
var highest int
users, err := DB.List("users/by-id")
if err != nil {
return 1
}
for _, doc := range users {
fields := strings.Split(doc, "/")
id, err := strconv.Atoi(fields[len(fields)-1])
if err != nil {
continue
}
if id > highest {
highest = id
}
}
// Return the highest +1
return highest + 1
}
// DB key for users by ID number.
func (u *User) key() string {
return fmt.Sprintf("users/by-id/%d", u.ID)
}
// DB key for users by username.
func (u *User) nameKey() string {
return "users/by-name/" + u.Username
}