From b07f1b080aeedb31d40671a1cf47a77834341551 Mon Sep 17 00:00:00 2001 From: Andrew-71 Date: Sat, 12 Oct 2024 20:41:30 +0300 Subject: [PATCH 1/2] Refactor database to separate package --- .gitignore | 2 +- Makefile | 2 +- auth.go | 19 +++---- go.mod | 2 +- jwt.go | 17 ++++--- main.go | 7 ++- db.go => storage/sqlite/sqlite.go | 82 +++++++++++++++++-------------- storage/storage.go | 8 +++ user.go => storage/user.go | 7 ++- 9 files changed, 84 insertions(+), 62 deletions(-) rename db.go => storage/sqlite/sqlite.go (53%) create mode 100644 storage/storage.go rename user.go => storage/user.go (85%) diff --git a/.gitignore b/.gitignore index 27f7388..ec723ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -pye-auth +pye private.key data.db \ No newline at end of file diff --git a/Makefile b/Makefile index 62e8f1d..d0aed03 100644 --- a/Makefile +++ b/Makefile @@ -2,4 +2,4 @@ build: go build run: - go build && ./pye-auth \ No newline at end of file + go build && ./pye \ No newline at end of file diff --git a/auth.go b/auth.go index be7825f..c7114be 100644 --- a/auth.go +++ b/auth.go @@ -22,24 +22,18 @@ func Register(w http.ResponseWriter, r *http.Request) { if ok { email = strings.TrimSpace(email) password = strings.TrimSpace(password) - if !(validEmail(email) && validPass(password) && !emailExists(email)) { + if !(validEmail(email) && validPass(password) && !data.EmailExists(email)) { slog.Debug("Outcome", "email", validEmail(email), "pass", validPass(password), - "taken", !emailExists(email)) + "taken", !data.EmailExists(email)) http.Error(w, "invalid auth credentials", http.StatusBadRequest) return } - user, err := NewUser(email, password) + err := data.AddUser(email, password) if err != nil { - slog.Error("error creating a new user", "error", err) - http.Error(w, "error creating a new user", http.StatusInternalServerError) - return - } - err = addUser(user) - if err != nil { - slog.Error("error saving a new user", "error", err) - http.Error(w, "error saving a new user", http.StatusInternalServerError) + slog.Error("error adding a new user", "error", err) + http.Error(w, "error adding a new user", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) @@ -58,8 +52,9 @@ func Login(w http.ResponseWriter, r *http.Request) { if ok { email = strings.TrimSpace(email) password = strings.TrimSpace(password) - user, ok := byEmail(email) + user, ok := data.ByEmail(email) if !ok || !user.PasswordFits(password) { + w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`) http.Error(w, "you did something wrong", http.StatusUnauthorized) return } diff --git a/go.mod b/go.mod index 6945d55..f1a85ee 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module pye-auth +module git.a71.su/Andrew71/pye go 1.22 diff --git a/jwt.go b/jwt.go index c8fb916..749560e 100644 --- a/jwt.go +++ b/jwt.go @@ -11,17 +11,18 @@ import ( "os" "time" + "git.a71.su/Andrew71/pye/storage" "github.com/golang-jwt/jwt/v5" ) var ( - KeyFile = "private.key" - key *rsa.PrivateKey + KeyFile = "private.key" + key *rsa.PrivateKey ) // LoadKey attempts to load a private key from KeyFile. // If the file does not exist, it generates a new key (and saves it) -func LoadKey() { +func MustLoadKey() { // If the key doesn't exist, create it if _, err := os.Stat(KeyFile); errors.Is(err, os.ErrNotExist) { key, err = rsa.GenerateKey(rand.Reader, 4096) @@ -59,6 +60,10 @@ func LoadKey() { } } +func init() { + MustLoadKey() +} + // publicKey returns our public key as PEM block func publicKey(w http.ResponseWriter, r *http.Request) { key_marshalled := x509.MarshalPKCS1PublicKey(&key.PublicKey) @@ -66,7 +71,7 @@ func publicKey(w http.ResponseWriter, r *http.Request) { pem.Encode(w, &block) } -func CreateJWT(usr User) (string, error) { +func CreateJWT(usr storage.User) (string, error) { t := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ "iss": "pye", @@ -99,7 +104,3 @@ func VerifyJWT(token string, publicKey []byte) bool { slog.Info("Error check", "err", err) return err == nil } - -func init() { - LoadKey() -} diff --git a/main.go b/main.go index 08969f7..1ef5c1e 100644 --- a/main.go +++ b/main.go @@ -3,8 +3,13 @@ package main import ( "fmt" "net/http" + + "git.a71.su/Andrew71/pye/storage" + "git.a71.su/Andrew71/pye/storage/sqlite" ) +var data storage.Storage = sqlite.MustLoadSQLite() + func main() { fmt.Println("=== Working on port 7102 ===") @@ -16,7 +21,7 @@ func main() { router.HandleFunc("POST /login", Login) // Note: likely temporary, possibly to be replaced by a fake "frontend" - router.HandleFunc("GET /login", Login) + router.HandleFunc("GET /login", Login) router.HandleFunc("GET /register", Register) http.ListenAndServe(":7102", router) diff --git a/db.go b/storage/sqlite/sqlite.go similarity index 53% rename from db.go rename to storage/sqlite/sqlite.go index d720bc6..79bf614 100644 --- a/db.go +++ b/storage/sqlite/sqlite.go @@ -1,4 +1,4 @@ -package main +package sqlite import ( "database/sql" @@ -6,6 +6,7 @@ import ( "log/slog" "os" + "git.a71.su/Andrew71/pye/storage" _ "github.com/mattn/go-sqlite3" ) @@ -18,11 +19,49 @@ const create string = ` );` var ( - DataFile = "data.db" - db *sql.DB = LoadDb() + DataFile = "data.db" ) -func LoadDb() *sql.DB { +type SQLiteStorage struct { + db *sql.DB +} + +func (s SQLiteStorage) AddUser(email, password string) error { + user, err := storage.NewUser(email, password) + if err != nil { + return err + } + _, err = s.db.Exec("insert into users (uuid, email, password) values ($1, $2, $3)", + user.Uuid.String(), user.Email, user.Hash) + if err != nil { + slog.Error("error adding user to database", "error", err, "user", user) + return err + } + return nil +} + +func (s SQLiteStorage) ById(uuid string) (storage.User, bool) { + row := s.db.QueryRow("select * from users where uuid = $1", uuid) + user := storage.User{} + err := row.Scan(&user.Uuid, &user.Email, &user.Hash) + + return user, err == nil +} + +func (s SQLiteStorage) ByEmail(email string) (storage.User, bool) { + row := s.db.QueryRow("select * from users where email = $1", email) + user := storage.User{} + err := row.Scan(&user.Uuid, &user.Email, &user.Hash) + + return user, err == nil +} + +func (s SQLiteStorage) EmailExists(email string) bool { + _, ok := s.ByEmail(email) + return ok +} + +func MustLoadSQLite() SQLiteStorage { // I *think* we need some file, even if only empty if _, err := os.Stat(DataFile); errors.Is(err, os.ErrNotExist) { slog.Error("sqlite3 database file required", "file", DataFile) @@ -33,41 +72,12 @@ func LoadDb() *sql.DB { slog.Error("error opening database", "error", err) os.Exit(1) } + + // TODO: Apparently "prepare" works here if _, err := db.Exec(create); err != nil && err.Error() != "table \"users\" already exists" { slog.Info("error initialising database table", "error", err) os.Exit(1) } slog.Info("loaded database") - return db -} - -func addUser(user User) error { - _, err := db.Exec("insert into users (uuid, email, password) values ($1, $2, $3)", - user.Uuid.String(), user.Email, user.Hash) - if err != nil { - slog.Error("error adding user", "error", err, "user", user) - return err - } - return nil -} - -func byId(uuid string) (User, bool) { - row := db.QueryRow("select * from users where uuid = $1", uuid) - user := User{} - err := row.Scan(&user.Uuid, &user.Email, &user.Hash) - - return user, err == nil -} - -func byEmail(email string) (User, bool) { - row := db.QueryRow("select * from users where email = $1", email) - user := User{} - err := row.Scan(&user.Uuid, &user.Email, &user.Hash) - - return user, err == nil -} - -func emailExists(email string) bool { - _, ok := byEmail(email) - return ok + return SQLiteStorage{db} } diff --git a/storage/storage.go b/storage/storage.go new file mode 100644 index 0000000..dcee5fe --- /dev/null +++ b/storage/storage.go @@ -0,0 +1,8 @@ +package storage + +type Storage interface { + AddUser(email, password string) error + ById(uuid string) (User, bool) + ByEmail(uuid string) (User, bool) + EmailExists(email string) bool +} diff --git a/user.go b/storage/user.go similarity index 85% rename from user.go rename to storage/user.go index abbfb7a..5c34a5d 100644 --- a/user.go +++ b/storage/user.go @@ -1,6 +1,8 @@ -package main +package storage import ( + "log/slog" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -19,7 +21,8 @@ func (u User) PasswordFits(password string) bool { func NewUser(email, password string) (User, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), 14) if err != nil { + slog.Error("error creating a new user", "error", err) return User{}, err } return User{uuid.New(), email, hash}, nil -} \ No newline at end of file +} From cda8f0cc1b2de1515e5bc7685c054f19c651f4f0 Mon Sep 17 00:00:00 2001 From: Andrew-71 Date: Sat, 12 Oct 2024 21:45:00 +0300 Subject: [PATCH 2/2] Refactor everything --- .gitignore | 2 +- Makefile | 7 ++++-- README.md | 12 ++++++---- auth.go => auth/auth.go | 8 ++++--- jwt.go => auth/jwt.go | 33 +++++++++++++------------- cmd/main.go | 51 ++++++++++++++++++++++++++++++++++++++++ cmd/serve/main.go | 32 +++++++++++++++++++++++++ cmd/verify/main.go | 17 ++++++++++++++ config/config.go | 20 ++++++++++++++++ main.go | 25 ++------------------ storage/sqlite/sqlite.go | 14 ++++------- 11 files changed, 162 insertions(+), 59 deletions(-) rename auth.go => auth/auth.go (90%) rename jwt.go => auth/jwt.go (71%) create mode 100644 cmd/main.go create mode 100644 cmd/serve/main.go create mode 100644 cmd/verify/main.go create mode 100644 config/config.go diff --git a/.gitignore b/.gitignore index ec723ee..c5e0910 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ pye private.key -data.db \ No newline at end of file +dev-data.db \ No newline at end of file diff --git a/Makefile b/Makefile index d0aed03..2cbf643 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ build: go build -run: - go build && ./pye \ No newline at end of file +serve: + go build && ./pye serve + +dev: + go build && ./pye serve --db dev-data.db \ No newline at end of file diff --git a/README.md b/README.md index 14f190b..4036fda 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,14 @@ in a state that proves I am competent Go developer. ## Current functionality -* Port `7102` +## `serve` + * `POST /register` - register a user with Basic Auth * `POST /login` - get a JWT token by Basic Auth * `GET /pem` - get PEM-encoded public RS256 key -* Data persistently stored in an SQLite database `data.db` -(requires creation of empty db) -* RS256 key loaded from `private.key` file or generated on startup if missing \ No newline at end of file +* Data persistently stored in an SQLite database +* RS256 key loaded from a file or generated on startup if missing + +## `verify` + +* Verify JWT via public key in a PEM file \ No newline at end of file diff --git a/auth.go b/auth/auth.go similarity index 90% rename from auth.go rename to auth/auth.go index c7114be..a74cc5f 100644 --- a/auth.go +++ b/auth/auth.go @@ -1,10 +1,12 @@ -package main +package auth import ( "log/slog" "net/http" "net/mail" "strings" + + "git.a71.su/Andrew71/pye/storage" ) func validEmail(email string) bool { @@ -16,7 +18,7 @@ func validPass(pass string) bool { return len(pass) >= 8 } -func Register(w http.ResponseWriter, r *http.Request) { +func Register(w http.ResponseWriter, r *http.Request, data storage.Storage) { email, password, ok := r.BasicAuth() if ok { @@ -46,7 +48,7 @@ func Register(w http.ResponseWriter, r *http.Request) { http.Error(w, "This API requires authorization", http.StatusUnauthorized) } -func Login(w http.ResponseWriter, r *http.Request) { +func Login(w http.ResponseWriter, r *http.Request, data storage.Storage) { email, password, ok := r.BasicAuth() if ok { diff --git a/jwt.go b/auth/jwt.go similarity index 71% rename from jwt.go rename to auth/jwt.go index 749560e..20f4c7a 100644 --- a/jwt.go +++ b/auth/jwt.go @@ -1,4 +1,4 @@ -package main +package auth import ( "crypto/rand" @@ -11,12 +11,12 @@ import ( "os" "time" - "git.a71.su/Andrew71/pye/storage" + "git.a71.su/Andrew71/pye/config" + "git.a71.su/Andrew71/pye/storage" "github.com/golang-jwt/jwt/v5" ) var ( - KeyFile = "private.key" key *rsa.PrivateKey ) @@ -24,7 +24,7 @@ var ( // If the file does not exist, it generates a new key (and saves it) func MustLoadKey() { // If the key doesn't exist, create it - if _, err := os.Stat(KeyFile); errors.Is(err, os.ErrNotExist) { + if _, err := os.Stat(config.Cfg.KeyFile); errors.Is(err, os.ErrNotExist) { key, err = rsa.GenerateKey(rand.Reader, 4096) if err != nil { slog.Error("error generating key", "error", err) @@ -34,7 +34,7 @@ func MustLoadKey() { // Save key to disk km := x509.MarshalPKCS1PrivateKey(key) block := pem.Block{Bytes: km, Type: "RSA PRIVATE KEY"} - f, err := os.OpenFile(KeyFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + f, err := os.OpenFile(config.Cfg.KeyFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { slog.Error("error opening/creating file", "error", err) os.Exit(1) @@ -44,9 +44,9 @@ func MustLoadKey() { slog.Error("error closing file", "error", err) os.Exit(1) } - slog.Info("generated new key") + slog.Info("generated new key", "file", config.Cfg.KeyFile) } else { - km, err := os.ReadFile(KeyFile) + km, err := os.ReadFile(config.Cfg.KeyFile) if err != nil { slog.Error("error reading key", "error", err) os.Exit(1) @@ -56,7 +56,7 @@ func MustLoadKey() { slog.Error("error parsing key", "error", err) os.Exit(1) } - slog.Info("loaded private key") + slog.Info("loaded private key", "file", config.Cfg.KeyFile) } } @@ -64,19 +64,19 @@ func init() { MustLoadKey() } -// publicKey returns our public key as PEM block -func publicKey(w http.ResponseWriter, r *http.Request) { +// PublicKey returns our public key as PEM block over http +func PublicKey(w http.ResponseWriter, r *http.Request) { key_marshalled := x509.MarshalPKCS1PublicKey(&key.PublicKey) block := pem.Block{Bytes: key_marshalled, Type: "RSA PUBLIC KEY"} pem.Encode(w, &block) } -func CreateJWT(usr storage.User) (string, error) { +func CreateJWT(user storage.User) (string, error) { t := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ "iss": "pye", - "uid": usr.Uuid, - "sub": usr.Email, + "uid": user.Uuid, + "sub": user.Email, "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour * 24 * 7).Unix(), }) @@ -90,8 +90,8 @@ func CreateJWT(usr storage.User) (string, error) { // VerifyToken receives a JWT and PEM-encoded public key, // then returns whether the token is valid -func VerifyJWT(token string, publicKey []byte) bool { - _, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { +func VerifyJWT(token string, publicKey []byte) (*jwt.Token, error) { + t, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { key, err := jwt.ParseRSAPublicKeyFromPEM(publicKey) if err != nil { return nil, err @@ -101,6 +101,5 @@ func VerifyJWT(token string, publicKey []byte) bool { } return key, nil }) - slog.Info("Error check", "err", err) - return err == nil + return t, err } diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..64f9fa3 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "flag" + "fmt" + "os" + + "git.a71.su/Andrew71/pye/cmd/serve" + "git.a71.su/Andrew71/pye/cmd/verify" + "git.a71.su/Andrew71/pye/config" +) + +func Run() { + // configFlag := flag.String("config", "", "override config file") + // flag.Parse() + // if *configFlag != "" { + // config.Load() + // } + + serveCmd := flag.NewFlagSet("serve", flag.ExitOnError) + servePort := serveCmd.Int("port", 0, "override port") + serveDb := serveCmd.String("db", "", "override sqlite database") + + verifyCmd := flag.NewFlagSet("verify", flag.ExitOnError) + + if len(os.Args) < 2 { + fmt.Println("expected 'serve' or 'verify' subcommands") + os.Exit(0) + } + + switch os.Args[1] { + case "serve": + serveCmd.Parse(os.Args[2:]) + if *servePort != 0 { + config.Cfg.Port = *servePort + } + if *serveDb != "" { + config.Cfg.SQLiteFile = *serveDb + } + serve.Serve() + case "verify": + verifyCmd.Parse(os.Args[2:]) + if len(os.Args) != 4 { + fmt.Println("Usage: ") + } + verify.Verify(os.Args[2], os.Args[3]) + default: + fmt.Println("expected 'serve' or 'verify' subcommands") + os.Exit(0) + } +} diff --git a/cmd/serve/main.go b/cmd/serve/main.go new file mode 100644 index 0000000..978a9df --- /dev/null +++ b/cmd/serve/main.go @@ -0,0 +1,32 @@ +package serve + +import ( + "log/slog" + "net/http" + "strconv" + + "git.a71.su/Andrew71/pye/auth" + "git.a71.su/Andrew71/pye/config" + "git.a71.su/Andrew71/pye/storage" + "git.a71.su/Andrew71/pye/storage/sqlite" +) + +var data storage.Storage + +func Serve() { + data = sqlite.MustLoadSQLite(config.Cfg.SQLiteFile) + + router := http.NewServeMux() + + router.HandleFunc("GET /pem", auth.PublicKey) + + router.HandleFunc("POST /register", func(w http.ResponseWriter, r *http.Request) { auth.Register(w, r, data) }) + router.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) { auth.Login(w, r, data) }) + + // Note: likely temporary, possibly to be replaced by a fake "frontend" + router.HandleFunc("GET /register", func(w http.ResponseWriter, r *http.Request) { auth.Register(w, r, data) }) + router.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) { auth.Login(w, r, data) }) + + slog.Info("🪐 pye started", "port", config.Cfg.Port) + http.ListenAndServe(":"+strconv.Itoa(config.Cfg.Port), router) +} diff --git a/cmd/verify/main.go b/cmd/verify/main.go new file mode 100644 index 0000000..e66e209 --- /dev/null +++ b/cmd/verify/main.go @@ -0,0 +1,17 @@ +package verify + +import ( + "log/slog" + "os" + + "git.a71.su/Andrew71/pye/auth" +) + +func Verify(token, filename string) { + key, err := os.ReadFile(filename) + if err != nil { + slog.Error("error reading file", "error", err, "file", filename) + } + t, err := auth.VerifyJWT(token, key) + slog.Info("result", "token", t, "error", err, "ok", err == nil) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..d05fa2a --- /dev/null +++ b/config/config.go @@ -0,0 +1,20 @@ +package config + +type Config struct { + Port int `json:"port"` + KeyFile string `json:"key-file"` + SQLiteFile string `json:"sqlite-file"` +} + +var DefaultConfig = Config{ + Port: 7102, + KeyFile: "private.key", + SQLiteFile: "data.db", +} + +var Cfg = MustLoadConfig() + +// TODO: Implement +func MustLoadConfig() Config { + return DefaultConfig +} \ No newline at end of file diff --git a/main.go b/main.go index 1ef5c1e..1d44e82 100644 --- a/main.go +++ b/main.go @@ -1,28 +1,7 @@ package main -import ( - "fmt" - "net/http" - - "git.a71.su/Andrew71/pye/storage" - "git.a71.su/Andrew71/pye/storage/sqlite" -) - -var data storage.Storage = sqlite.MustLoadSQLite() +import "git.a71.su/Andrew71/pye/cmd" func main() { - fmt.Println("=== Working on port 7102 ===") - - router := http.NewServeMux() - - router.HandleFunc("GET /pem", publicKey) - - router.HandleFunc("POST /register", Register) - router.HandleFunc("POST /login", Login) - - // Note: likely temporary, possibly to be replaced by a fake "frontend" - router.HandleFunc("GET /login", Login) - router.HandleFunc("GET /register", Register) - - http.ListenAndServe(":7102", router) + cmd.Run() } diff --git a/storage/sqlite/sqlite.go b/storage/sqlite/sqlite.go index 79bf614..1a835d4 100644 --- a/storage/sqlite/sqlite.go +++ b/storage/sqlite/sqlite.go @@ -18,10 +18,6 @@ const create string = ` PRIMARY KEY("uuid") );` -var ( - DataFile = "data.db" -) - type SQLiteStorage struct { db *sql.DB } @@ -61,13 +57,13 @@ func (s SQLiteStorage) EmailExists(email string) bool { return ok } -func MustLoadSQLite() SQLiteStorage { +func MustLoadSQLite(dataFile string) SQLiteStorage { // I *think* we need some file, even if only empty - if _, err := os.Stat(DataFile); errors.Is(err, os.ErrNotExist) { - slog.Error("sqlite3 database file required", "file", DataFile) + if _, err := os.Stat(dataFile); errors.Is(err, os.ErrNotExist) { + slog.Error("sqlite3 database file required", "file", dataFile) os.Exit(1) } - db, err := sql.Open("sqlite3", DataFile) + db, err := sql.Open("sqlite3", dataFile) if err != nil { slog.Error("error opening database", "error", err) os.Exit(1) @@ -78,6 +74,6 @@ func MustLoadSQLite() SQLiteStorage { slog.Info("error initialising database table", "error", err) os.Exit(1) } - slog.Info("loaded database") + slog.Info("loaded database", "file", dataFile) return SQLiteStorage{db} }