diff options
Diffstat (limited to 'walrss/internal')
| -rw-r--r-- | walrss/internal/core/sessions.go | 40 | ||||
| -rw-r--r-- | walrss/internal/core/userError.go | 52 | ||||
| -rw-r--r-- | walrss/internal/core/users.go | 61 | ||||
| -rw-r--r-- | walrss/internal/core/util.go | 15 | ||||
| -rw-r--r-- | walrss/internal/core/validation.go | 21 | ||||
| -rw-r--r-- | walrss/internal/db/db.go | 39 | ||||
| -rw-r--r-- | walrss/internal/http/auth.go | 29 | ||||
| -rw-r--r-- | walrss/internal/http/http.go | 49 | ||||
| -rw-r--r-- | walrss/internal/state/state.go | 54 | ||||
| -rw-r--r-- | walrss/internal/urls/urls.go | 9 |
10 files changed, 369 insertions, 0 deletions
diff --git a/walrss/internal/core/sessions.go b/walrss/internal/core/sessions.go new file mode 100644 index 0000000..8e1ab73 --- /dev/null +++ b/walrss/internal/core/sessions.go @@ -0,0 +1,40 @@ +package core + +import ( + "crypto/rand" + "encoding/hex" + goalone "github.com/bwmarrin/go-alone" + "time" +) + +var ( + sessionSigner *goalone.Sword + sessionSalt = []byte("session") +) + +func init() { + sessionSecret := make([]byte, 50) + if _, err := rand.Read(sessionSecret); err != nil { + panic(err) + } + sessionSigner = goalone.New(sessionSecret, goalone.Timestamp) +} + +func GenerateSessionToken(userID string) string { + combined := combineStringAndSalt(userID, sessionSalt) + return hex.EncodeToString(sessionSigner.Sign(combined)) +} + +func ValidateSessionToken(input string) (string, time.Time, error) { + signed, err := hex.DecodeString(input) + if err != nil { + return "", time.Time{}, err + } + + if _, err := sessionSigner.Unsign(signed); err != nil { + return "", time.Time{}, AsUserError(400, err) + } + + parsed := sessionSigner.Parse(signed) + return string(parsed.Payload), parsed.Timestamp, nil +} diff --git a/walrss/internal/core/userError.go b/walrss/internal/core/userError.go new file mode 100644 index 0000000..4938466 --- /dev/null +++ b/walrss/internal/core/userError.go @@ -0,0 +1,52 @@ +package core + +import "fmt" + +var ErrNotFound = NewUserErrorWithStatus(404, "item not found") + +type UserError struct { + Original error + Status int +} + +func (ue *UserError) Error() string { + return ue.Original.Error() +} + +func (ue *UserError) Unwrap() error { + return ue.Original +} + +func AsUserError(status int, err error) error { + return &UserError{ + Original: err, + Status: status, + } +} + +func NewUserError(format string, args ...any) error { + return NewUserErrorWithStatus(400, format, args...) +} + +func NewUserErrorWithStatus(status int, format string, args ...any) error { + return &UserError{ + Original: fmt.Errorf(format, args...), + Status: status, + } +} + +func IsUserError(err error) bool { + if err == nil { + return false + } + _, ok := err.(*UserError) + return ok +} + +func GetUserErrorStatus(err error) int { + ue, ok := err.(*UserError) + if !ok { + return 0 + } + return ue.Status +} diff --git a/walrss/internal/core/users.go b/walrss/internal/core/users.go new file mode 100644 index 0000000..f909a1d --- /dev/null +++ b/walrss/internal/core/users.go @@ -0,0 +1,61 @@ +package core + +import ( + "errors" + "github.com/codemicro/walrss/walrss/internal/db" + "github.com/codemicro/walrss/walrss/internal/state" + "github.com/lithammer/shortuuid/v4" + bh "github.com/timshannon/bolthold" + "golang.org/x/crypto/bcrypt" +) + +func RegisterUser(st *state.State, email, password string) (*db.User, error) { + if err := validateEmailAddress(email); err != nil { + return nil, err + } + + if err := validatePassword(password); err != nil { + return nil, err + } + + u := &db.User{ + ID: shortuuid.New(), + Email: email, + Salt: generateRandomData(30), + } + + hash, err := bcrypt.GenerateFromPassword(combineStringAndSalt(password, u.Salt), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + u.Password = hash + + if err := st.Data.Insert(bh.Key, u); err != nil { + if errors.Is(err, bh.ErrUniqueExists) { + return nil, NewUserError("email address in use") + } + return nil, err + } + + return u, nil +} + +func AreUserCredentialsCorrect(st *state.State, email, password string) (bool, error) { + user := new(db.User) + if err := st.Data.FindOne(user, bh.Where("Email").Eq(email)); err != nil { + if errors.Is(err, bh.ErrNotFound) { + return false, ErrNotFound + } + return false, err + } + + if err := bcrypt.CompareHashAndPassword(user.Password, combineStringAndSalt(password, user.Salt)); err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return false, nil + } + return false, err + } + + return true, nil +} diff --git a/walrss/internal/core/util.go b/walrss/internal/core/util.go new file mode 100644 index 0000000..f92d365 --- /dev/null +++ b/walrss/internal/core/util.go @@ -0,0 +1,15 @@ +package core + +import ( + "crypto/rand" +) + +func generateRandomData(n int) []byte { + bytes := make([]byte, n) + _, _ = rand.Read(bytes) + return bytes +} + +func combineStringAndSalt(password string, salt []byte) []byte { + return append([]byte(password), salt...) +} diff --git a/walrss/internal/core/validation.go b/walrss/internal/core/validation.go new file mode 100644 index 0000000..501c023 --- /dev/null +++ b/walrss/internal/core/validation.go @@ -0,0 +1,21 @@ +package core + +import ( + "regexp" +) + +var emailRegexp = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") + +func validateEmailAddress(email string) error { + if !emailRegexp.MatchString(email) { + return NewUserError("invalid email address") + } + return nil +} + +func validatePassword(password string) error { + if len(password) <= 3 { + return NewUserError("password must be at least three characters long") + } + return nil +} diff --git a/walrss/internal/db/db.go b/walrss/internal/db/db.go new file mode 100644 index 0000000..efb3f46 --- /dev/null +++ b/walrss/internal/db/db.go @@ -0,0 +1,39 @@ +package db + +import ( + bh "github.com/timshannon/bolthold" +) + +func New(filename string) (*bh.Store, error) { + store, err := bh.Open(filename, 0644, nil) + if err != nil { + return nil, err + } + return store, nil +} + +type User struct { + ID string `boldholdKey:""` + Email string `boltholdUnique:"UniqueEmail" boltholdIndex:"Email"` + Password []byte + Salt []byte + + Schedule struct { + Day SendDay `boltholdIndex:"Day"` + Hour int `boltholdIndex:"Hour"` + } +} + +type SendDay uint32 + +const ( + SendDayNever = iota + SendDaily + SendOnMonday + SendOnTuesday + SendOnWednesday + SendOnThursday + SendOnFriday + SendOnSaturday + SendOnSunday +) diff --git a/walrss/internal/http/auth.go b/walrss/internal/http/auth.go new file mode 100644 index 0000000..7be4078 --- /dev/null +++ b/walrss/internal/http/auth.go @@ -0,0 +1,29 @@ +package http + +import ( + "github.com/codemicro/walrss/walrss/internal/core" + "github.com/gofiber/fiber/v2" + "time" +) + +func (s *Server) authRegister(ctx *fiber.Ctx) error { + user, err := core.RegisterUser(s.state, + ctx.FormValue("email"), + ctx.FormValue("password"), + ) + if err != nil { + return err + } + + token := core.GenerateSessionToken(user.ID) + + ctx.Cookie(&fiber.Cookie{ + Name: sessionCookieKey, + Value: token, + Expires: time.Now().UTC().Add(sessionDuration), + Secure: !s.state.Config.Debug, + HTTPOnly: true, + }) + + return ctx.SendString("ok!") +} diff --git a/walrss/internal/http/http.go b/walrss/internal/http/http.go new file mode 100644 index 0000000..5af260e --- /dev/null +++ b/walrss/internal/http/http.go @@ -0,0 +1,49 @@ +package http + +import ( + "github.com/codemicro/walrss/walrss/internal/core" + "github.com/codemicro/walrss/walrss/internal/state" + "github.com/codemicro/walrss/walrss/internal/urls" + "github.com/gofiber/fiber/v2" + "time" +) + +const ( + sessionCookieKey = "walrss-session" + sessionDuration = (time.Hour * 24) * 7 // 7 days +) + +type Server struct { + state *state.State + app *fiber.App +} + +func New(st *state.State) (*Server, error) { + app := fiber.New(fiber.Config{ + DisableStartupMessage: !st.Config.Debug, + AppName: "Walrss", + }) + // TODO: Add error handler with UserError support + + s := &Server{ + state: st, + app: app, + } + + s.registerHandlers() + + return s, nil +} + +func (s *Server) registerHandlers() { + s.app.Post(urls.AuthRegister, s.authRegister) +} + +func (s *Server) Run() error { + return s.app.Listen(s.state.Config.GetHTTPAddress()) +} + +func UserErrorToResponse(ctx *fiber.Ctx, ue core.UserError) error { + ctx.Status(ue.Status) + return ctx.SendString(ue.Error()) +} diff --git a/walrss/internal/state/state.go b/walrss/internal/state/state.go new file mode 100644 index 0000000..54badd7 --- /dev/null +++ b/walrss/internal/state/state.go @@ -0,0 +1,54 @@ +package state + +import ( + "errors" + "fmt" + "github.com/kkyr/fig" + bh "github.com/timshannon/bolthold" + "io/ioutil" + "os" +) + +type State struct { + Config *Config + Data *bh.Store +} + +func New() *State { + return &State{} +} + +type Config struct { + Server struct { + Host string `fig:"host" default:"127.0.0.1"` + Port int `fig:"port" default:"8080"` + } + DataDirectory string `fig:"dataDir" default:"./"` + Debug bool `fig:"debug"` +} + +const configFilename = "config.yaml" + +func LoadConfig() (*Config, error) { + // If the file doesn't exist, Fig will throw a hissy fit, so we should create a blank one if it doesn't exist + if _, err := os.Stat(configFilename); err != nil { + if errors.Is(err, os.ErrNotExist) { + // If the file doesn't have contents, Fig will throw an EOF, despite `touch config.yaml` working fine. idk lol + if err := ioutil.WriteFile(configFilename, []byte("{}"), 0777); err != nil { + return nil, err + } + } else { + return nil, err + } + } + + cfg := new(Config) + if err := fig.Load(cfg); err != nil { + return nil, err + } + return cfg, nil +} + +func (cfg *Config) GetHTTPAddress() string { + return fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) +} diff --git a/walrss/internal/urls/urls.go b/walrss/internal/urls/urls.go new file mode 100644 index 0000000..1d38f96 --- /dev/null +++ b/walrss/internal/urls/urls.go @@ -0,0 +1,9 @@ +package urls + +const ( + Index = "/" + + Auth = "/auth" + AuthSignIn = Auth + "/signin" + AuthRegister = Auth + "/register" +)
\ No newline at end of file |
