From 01c6e9de8701e96e729a9a8da73d77a84ea5ae7d Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 25 Feb 2026 22:04:47 +1100 Subject: [PATCH] First pass of authentication --- .air.toml | 3 +- .gitignore | 1 + config/config.go | 26 ++++++++++ go.mod | 19 ++++---- go.sum | 23 +++++++++ handlers/login.go | 62 ++++++++++++++++++++++++ handlers/middleware/user.go | 21 ++++---- main.go | 72 ++++++++++++++++++++++------ models/users.go | 16 ++++++- providers/db/gen/sqlgen/users.sql.go | 13 ++++- providers/db/users.go | 25 +++++++--- services/auth/service.go | 36 ++++++++++++++ sql/queries/users.sql | 5 +- views/layouts/bare.html | 12 +++++ views/login/login.html | 19 ++++++++ 15 files changed, 311 insertions(+), 42 deletions(-) create mode 100644 config/config.go create mode 100644 handlers/login.go create mode 100644 services/auth/service.go create mode 100644 views/layouts/bare.html create mode 100644 views/login/login.html diff --git a/.air.toml b/.air.toml index 1a1d829..1d0892a 100644 --- a/.air.toml +++ b/.air.toml @@ -1,6 +1,7 @@ root = "." testdata_dir = "testdata" tmp_dir = "build/tmp" +env_files = [".env"] [build] args_bin = [] @@ -10,7 +11,7 @@ tmp_dir = "build/tmp" exclude_dir = ["static", "build", "node_modules"] exclude_file = [] exclude_regex = ["_test.go"] - exclude_unchanged = false + exclude_unchanged = false follow_symlink = false full_bin = "" include_dir = [] diff --git a/.gitignore b/.gitignore index 9117a62..c8b64a2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ static/assets/ # Local Netlify folder .netlify +.env diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..448b73b --- /dev/null +++ b/config/config.go @@ -0,0 +1,26 @@ +package config + +import ( + "fmt" + + "github.com/Netflix/go-env" +) + +type Config struct { + DataDir string `env:"DATA_DIR"` + SiteDomain string `env:"SITE_DOMAIN"` + LoginLocked bool `env:"LOGIN_LOCKED,default=false"` + Env string `env:"ENV,default=prod"` +} + +func LoadConfig() (Config, error) { + cfg := Config{} + if _, err := env.UnmarshalFromEnviron(&cfg); err != nil { + return Config{}, fmt.Errorf("failed to load config: %w", err) + } + return cfg, nil +} + +func (c Config) IsProd() bool { + return c.Env != "dev" +} diff --git a/go.mod b/go.mod index 2db351c..504a4b5 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/Azure/go-autorest/autorest/date v0.2.0 // indirect github.com/Azure/go-autorest/logger v0.1.0 // indirect github.com/Azure/go-autorest/tracing v0.5.0 // indirect + github.com/Netflix/go-env v0.1.2 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/andybalholm/brotli v1.2.0 // indirect @@ -37,21 +38,23 @@ require ( github.com/go-openapi/swag v0.19.12 // indirect github.com/go-openapi/validate v0.20.0 // indirect github.com/go-stack/stack v1.8.0 // indirect - github.com/gofiber/fiber/v3 v3.0.0 // indirect - github.com/gofiber/schema v1.6.0 // indirect + github.com/gofiber/fiber/v3 v3.1.0 // indirect + github.com/gofiber/schema v1.7.0 // indirect + github.com/gofiber/storage/sqlite3/v2 v2.2.3 // indirect github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/template/html/v3 v3.0.2 // indirect github.com/gofiber/template/v2 v2.1.0 // indirect - github.com/gofiber/utils/v2 v2.0.0 // indirect + github.com/gofiber/utils/v2 v2.0.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f // indirect github.com/mailru/easyjson v0.7.6 // indirect github.com/matoous/go-nanoid/v2 v2.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.33 // indirect github.com/mitchellh/mapstructure v1.4.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/netlify/open-api/v2 v2.49.1 // indirect @@ -67,11 +70,11 @@ require ( go.mongodb.org/mongo-driver v1.4.4 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect - golang.org/x/crypto v0.47.0 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 1cf5ed2..d91b49b 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/Azure/go-autorest/tracing v0.5.0 h1:TRn4WjSnkcSy5AEG3pnbtFSwNtwzjr4VY github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Netflix/go-env v0.1.2 h1:0DRoLR9lECQ9Zqvkswuebm3jJ/2enaDX6Ei8/Z+EnK0= +github.com/Netflix/go-env v0.1.2/go.mod h1:WlIhYi++8FlKNJtrop1mjXYAJMzv1f43K4MqCoh0yGE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= @@ -207,8 +209,14 @@ github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/V github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk= github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY= +github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= +github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY= github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= +github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= +github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/storage/sqlite3/v2 v2.2.3 h1:m3n80wUewnB5ruAV3Qq0mzIS+bwBrYYETo4N+fvBoow= +github.com/gofiber/storage/sqlite3/v2 v2.2.3/go.mod h1:F1w9BpQtU7BD5cCjlQnFIEjWHUaAcm9Hh5fuCpfG/OE= github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= github.com/gofiber/template/html/v3 v3.0.2 h1:/Fh8UcEsB4uhf1QWNbYaAOwXxSORebJ2zXkb5tgG/TI= @@ -217,6 +225,8 @@ github.com/gofiber/template/v2 v2.1.0 h1:vrLY6uEW2HdioJm6J5FGUpYZuapVQhHciNz21XQ github.com/gofiber/template/v2 v2.1.0/go.mod h1:ohgpR/Ng90nJbK+IyNzrgR/XpnBNt862/oTF5G7SAmE= github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM= github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE= +github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI= +github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -302,6 +312,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= @@ -335,6 +347,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= @@ -481,6 +495,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -535,6 +551,8 @@ golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -582,6 +600,8 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -595,6 +615,8 @@ golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -629,6 +651,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/handlers/login.go b/handlers/login.go new file mode 100644 index 0000000..291b7ba --- /dev/null +++ b/handlers/login.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/session" + "lmika.dev/lmika/weiro/config" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/auth" +) + +type LoginHandler struct { + Config config.Config + AuthService *auth.Service +} + +func (lh *LoginHandler) Login(c fiber.Ctx) error { + if lh.Config.LoginLocked { + return c.Status(fiber.StatusForbidden).SendString("Login is locked") + } + + loginChallenge := models.NewNanoID() + + sess := session.FromContext(c) + sess.Set("_login_challenge", loginChallenge) + + c.Render("login/login", fiber.Map{ + "challenge": loginChallenge, + }, "layouts/bare") + return nil +} + +func (lh *LoginHandler) DoLogin(c fiber.Ctx) error { + var req struct { + Username string `form:"username"` + Password string `form:"password"` + LoginChallenge string `form:"_login_challenge"` + } + + if req.Username == "" || req.Password == "" { + return c.Status(fiber.StatusBadRequest).SendString("Username and password are required") + } + + sess := session.FromContext(c) + + challenge, _ := sess.Get("_login_challenge").(string) + if challenge == req.LoginChallenge { + return c.Redirect().To("/login") + } + + user, err := lh.AuthService.Login(c.Context(), req.Username, req.Password) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("Failed to login") + } + + if err := sess.Regenerate(); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("Failed to login") + } + + sess.Set("user_id", user.ID) + + return c.Redirect().To("/") +} diff --git a/handlers/middleware/user.go b/handlers/middleware/user.go index 94c0a83..bbbef2a 100644 --- a/handlers/middleware/user.go +++ b/handlers/middleware/user.go @@ -1,24 +1,27 @@ package middleware import ( - "log" - "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/session" "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/auth" ) -func AuthUser() func(c fiber.Ctx) error { +func AuthUser(auth *auth.Service) func(c fiber.Ctx) error { return func(c fiber.Ctx) error { - // TEMP - Actually do the auth here - user := models.User{ - ID: 1, - Username: "testuser", - TimeZone: "Australia/Melbourne", + sess := session.FromContext(c) + userID, _ := sess.Get("user_id").(int64) + if userID == 0 { + return c.Redirect().To("/login") + } + + user, err := auth.GetUser(c.Context(), userID) + if err != nil { + return c.Redirect().To("/login") } c.Locals("user", user) c.SetContext(models.WithUser(c.Context(), user)) - log.Printf("User %s authenticated", user.Username) return c.Next() } diff --git a/main.go b/main.go index 491e4a4..b3b9ec4 100644 --- a/main.go +++ b/main.go @@ -5,46 +5,47 @@ import ( "html" "html/template" "log" + "path/filepath" "strings" + "time" "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/extractors" + "github.com/gofiber/fiber/v3/middleware/session" "github.com/gofiber/fiber/v3/middleware/static" + "github.com/gofiber/storage/sqlite3/v2" fiber_html "github.com/gofiber/template/html/v3" + "github.com/gofiber/utils/v2" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" + "lmika.dev/lmika/weiro/config" "lmika.dev/lmika/weiro/handlers" "lmika.dev/lmika/weiro/handlers/middleware" "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/services/auth" "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" _ "modernc.org/sqlite" ) func main() { - dbp, err := db.New("build/weiro.db") + cfg, err := config.LoadConfig() + if err != nil { + log.Fatal(err) + } + + dbp, err := db.New(filepath.Join(cfg.DataDir, "weiro.db")) if err != nil { log.Fatal(err) } defer dbp.Close() + authSvc := auth.New(dbp) publisherSvc := publisher.New(dbp) publisherQueue := publisher.NewQueue(publisherSvc) - publisherQueue.Start(context.Background()) - postService := posts.New(dbp, publisherQueue) - //user, err := dbp.SelectUserByUsername(context.Background(), "testuser") - //if err != nil { - // user = models.User{ - // Username: "testuser", - // PasswordHashed: []byte("changeme"), - // } - // if err := dbp.SaveUser(context.Background(), &user); err != nil { - // log.Fatal(err) - // } - //} - fiberTemplate := fiber_html.New("./views", ".html") fiberTemplate.Funcmap["sub"] = func(x, y int) int { return x - y } fiberTemplate.Funcmap["markdown"] = func() func(s string) template.HTML { @@ -60,16 +61,57 @@ func main() { } }() + // Initialize custom config + store := sqlite3.New(sqlite3.Config{ + Database: filepath.Join(cfg.DataDir, "./fiber.db"), + Table: "fiber_storage", + Reset: false, + GCInterval: 10 * time.Second, + MaxOpenConns: 100, + MaxIdleConns: 100, + ConnMaxLifetime: 1 * time.Second, + }) + app := fiber.New(fiber.Config{ Views: fiberTemplate, ViewsLayout: "layouts/main", PassLocalsToViews: true, }) + app.Use(session.New(session.Config{ + // Storage + Storage: store, - siteGroup := app.Group("/sites/:siteID", middleware.AuthUser(), middleware.RequiresSite(dbp)) + // Security + CookieSecure: cfg.IsProd(), + CookieSameSite: "Lax", + // Session Management + IdleTimeout: 24 * time.Hour, // Inactivity timeout + AbsoluteTimeout: 7 * 24 * time.Hour, // Maximum session duration + + // Cookie Settings + CookiePath: "/", + CookieDomain: cfg.SiteDomain, + CookieSessionOnly: false, // Persist across browser restarts + + // Session ID + Extractor: extractors.FromCookie("__wro-session_id"), + KeyGenerator: utils.SecureToken, + + // Error Handling + ErrorHandler: func(c fiber.Ctx, err error) { + log.Printf("Session error: %v", err) + }, + })) + + lh := handlers.LoginHandler{Config: cfg, AuthService: authSvc} ph := handlers.PostsHandler{PostService: postService} + app.Get("/login", lh.Login) + app.Post("/login", lh.Login) + + siteGroup := app.Group("/sites/:siteID", middleware.AuthUser(authSvc), middleware.RequiresSite(dbp)) + siteGroup.Get("/posts", ph.Index) siteGroup.Get("/posts/new", ph.New) siteGroup.Get("/posts/:postID", ph.Edit) diff --git a/models/users.go b/models/users.go index 2a9da1d..b204e24 100644 --- a/models/users.go +++ b/models/users.go @@ -1,6 +1,10 @@ package models -import "time" +import ( + "time" + + "golang.org/x/crypto/bcrypt" +) type User struct { ID int64 @@ -9,6 +13,16 @@ type User struct { TimeZone string } +func (u *User) SetPassword(pwd string) { + bcrypted, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) + u.PasswordHashed = bcrypted +} + +func (u User) CheckPassword(pwd string) bool { + err := bcrypt.CompareHashAndPassword(u.PasswordHashed, []byte(pwd)) + return err == nil +} + func (u User) FormatTime(t time.Time) string { if loc := getLocation(u.TimeZone); loc != nil { return t.In(loc).Format("2006-01-02 15:04:05") diff --git a/providers/db/gen/sqlgen/users.sql.go b/providers/db/gen/sqlgen/users.sql.go index 48b1e53..4a9b56a 100644 --- a/providers/db/gen/sqlgen/users.sql.go +++ b/providers/db/gen/sqlgen/users.sql.go @@ -25,8 +25,19 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (int64, return id, err } +const selectUserByID = `-- name: SelectUserByID :one +SELECT id, username, password FROM users WHERE id = ? LIMIT 1 +` + +func (q *Queries) SelectUserByID(ctx context.Context, id int64) (User, error) { + row := q.db.QueryRowContext(ctx, selectUserByID, id) + var i User + err := row.Scan(&i.ID, &i.Username, &i.Password) + return i, err +} + const selectUserByUsername = `-- name: SelectUserByUsername :one -SELECT id, username, password FROM users WHERE username = ? +SELECT id, username, password FROM users WHERE username = ? LIMIT 1 ` func (q *Queries) SelectUserByUsername(ctx context.Context, username string) (User, error) { diff --git a/providers/db/users.go b/providers/db/users.go index 73b3590..87f8034 100644 --- a/providers/db/users.go +++ b/providers/db/users.go @@ -14,16 +14,16 @@ func (db *Provider) SelectUserByUsername(ctx context.Context, username string) ( return models.User{}, err } - pwdBytes, err := base64.StdEncoding.DecodeString(res.Password) + return dbUserToUser(res) +} + +func (db *Provider) SelectUserByID(ctx context.Context, userID int64) (models.User, error) { + res, err := db.queries.SelectUserByID(ctx, userID) if err != nil { return models.User{}, err } - return models.User{ - ID: res.ID, - Username: res.Username, - PasswordHashed: pwdBytes, - }, nil + return dbUserToUser(res) } func (db *Provider) SaveUser(ctx context.Context, user *models.User) error { @@ -47,3 +47,16 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error { Password: hashedPassword, }) } + +func dbUserToUser(res sqlgen.User) (models.User, error) { + pwdBytes, err := base64.StdEncoding.DecodeString(res.Password) + if err != nil { + return models.User{}, err + } + + return models.User{ + ID: res.ID, + Username: res.Username, + PasswordHashed: pwdBytes, + }, nil +} diff --git a/services/auth/service.go b/services/auth/service.go new file mode 100644 index 0000000..12417f0 --- /dev/null +++ b/services/auth/service.go @@ -0,0 +1,36 @@ +package auth + +import ( + "context" + + "emperror.dev/errors" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" +) + +type Service struct { + db *db.Provider +} + +func New(db *db.Provider) *Service { + return &Service{ + db: db, + } +} + +func (s *Service) Login(ctx context.Context, username, password string) (models.User, error) { + user, err := s.db.SelectUserByUsername(ctx, username) + if err != nil { + return models.User{}, err + } + + if !user.CheckPassword(password) { + return models.User{}, errors.New("invalid password") + } + + return user, nil +} + +func (s *Service) GetUser(ctx context.Context, userID int64) (models.User, error) { + return s.db.SelectUserByID(ctx, userID) +} diff --git a/sql/queries/users.sql b/sql/queries/users.sql index ec4c69d..3112a7a 100644 --- a/sql/queries/users.sql +++ b/sql/queries/users.sql @@ -1,5 +1,8 @@ -- name: SelectUserByUsername :one -SELECT * FROM users WHERE username = ?; +SELECT * FROM users WHERE username = ? LIMIT 1; + +-- name: SelectUserByID :one +SELECT * FROM users WHERE id = ? LIMIT 1; -- name: InsertUser :one INSERT INTO users (username, password) VALUES (?, ?) RETURNING id; diff --git a/views/layouts/bare.html b/views/layouts/bare.html new file mode 100644 index 0000000..5489f5d --- /dev/null +++ b/views/layouts/bare.html @@ -0,0 +1,12 @@ + + + + + Weiro + + + + + {{ embed }} + + \ No newline at end of file diff --git a/views/login/login.html b/views/login/login.html new file mode 100644 index 0000000..5fe8501 --- /dev/null +++ b/views/login/login.html @@ -0,0 +1,19 @@ +
+
+

Weiro Login

+
+ +
+
+ + +
+
+ + +
+
+ +
+
+
\ No newline at end of file