aboutsummaryrefslogtreecommitdiffstats
path: root/walrss/internal
diff options
context:
space:
mode:
Diffstat (limited to 'walrss/internal')
-rw-r--r--walrss/internal/core/sessions.go40
-rw-r--r--walrss/internal/core/userError.go52
-rw-r--r--walrss/internal/core/users.go61
-rw-r--r--walrss/internal/core/util.go15
-rw-r--r--walrss/internal/core/validation.go21
-rw-r--r--walrss/internal/db/db.go39
-rw-r--r--walrss/internal/http/auth.go29
-rw-r--r--walrss/internal/http/http.go49
-rw-r--r--walrss/internal/state/state.go54
-rw-r--r--walrss/internal/urls/urls.go9
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