diff --git a/.gitignore b/.gitignore index c5e0910..27f7388 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -pye +pye-auth private.key -dev-data.db \ No newline at end of file +data.db \ No newline at end of file diff --git a/Makefile b/Makefile index 2cbf643..62e8f1d 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,5 @@ build: go build -serve: - go build && ./pye serve - -dev: - go build && ./pye serve --db dev-data.db \ No newline at end of file +run: + go build && ./pye-auth \ No newline at end of file diff --git a/README.md b/README.md index 4036fda..14f190b 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,10 @@ in a state that proves I am competent Go developer. ## Current functionality -## `serve` - +* Port `7102` * `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 -* 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 +* 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 diff --git a/auth/auth.go b/auth.go similarity index 71% rename from auth/auth.go rename to auth.go index a74cc5f..be7825f 100644 --- a/auth/auth.go +++ b/auth.go @@ -1,12 +1,10 @@ -package auth +package main import ( "log/slog" "net/http" "net/mail" "strings" - - "git.a71.su/Andrew71/pye/storage" ) func validEmail(email string) bool { @@ -18,24 +16,30 @@ func validPass(pass string) bool { return len(pass) >= 8 } -func Register(w http.ResponseWriter, r *http.Request, data storage.Storage) { +func Register(w http.ResponseWriter, r *http.Request) { email, password, ok := r.BasicAuth() if ok { email = strings.TrimSpace(email) password = strings.TrimSpace(password) - if !(validEmail(email) && validPass(password) && !data.EmailExists(email)) { + if !(validEmail(email) && validPass(password) && !emailExists(email)) { slog.Debug("Outcome", "email", validEmail(email), "pass", validPass(password), - "taken", !data.EmailExists(email)) + "taken", !emailExists(email)) http.Error(w, "invalid auth credentials", http.StatusBadRequest) return } - err := data.AddUser(email, password) + user, err := NewUser(email, password) if err != nil { - slog.Error("error adding a new user", "error", err) - http.Error(w, "error adding a new user", http.StatusInternalServerError) + 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) return } w.WriteHeader(http.StatusCreated) @@ -48,15 +52,14 @@ func Register(w http.ResponseWriter, r *http.Request, data storage.Storage) { http.Error(w, "This API requires authorization", http.StatusUnauthorized) } -func Login(w http.ResponseWriter, r *http.Request, data storage.Storage) { +func Login(w http.ResponseWriter, r *http.Request) { email, password, ok := r.BasicAuth() if ok { email = strings.TrimSpace(email) password = strings.TrimSpace(password) - user, ok := data.ByEmail(email) + user, ok := 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/cmd/main.go b/cmd/main.go deleted file mode 100644 index 64f9fa3..0000000 --- a/cmd/main.go +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 978a9df..0000000 --- a/cmd/serve/main.go +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index e66e209..0000000 --- a/cmd/verify/main.go +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index d05fa2a..0000000 --- a/config/config.go +++ /dev/null @@ -1,20 +0,0 @@ -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/db.go b/db.go new file mode 100644 index 0000000..d720bc6 --- /dev/null +++ b/db.go @@ -0,0 +1,73 @@ +package main + +import ( + "database/sql" + "errors" + "log/slog" + "os" + + _ "github.com/mattn/go-sqlite3" +) + +const create string = ` + CREATE TABLE "users" ( + "uuid" TEXT NOT NULL UNIQUE, + "email" TEXT NOT NULL UNIQUE, + "password" TEXT NOT NULL, + PRIMARY KEY("uuid") + );` + +var ( + DataFile = "data.db" + db *sql.DB = LoadDb() +) + +func LoadDb() *sql.DB { + // 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) + os.Exit(1) + } + db, err := sql.Open("sqlite3", DataFile) + if err != nil { + slog.Error("error opening database", "error", err) + os.Exit(1) + } + 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 +} diff --git a/go.mod b/go.mod index f1a85ee..6945d55 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module git.a71.su/Andrew71/pye +module pye-auth go 1.22 diff --git a/auth/jwt.go b/jwt.go similarity index 68% rename from auth/jwt.go rename to jwt.go index 20f4c7a..c8fb916 100644 --- a/auth/jwt.go +++ b/jwt.go @@ -1,4 +1,4 @@ -package auth +package main import ( "crypto/rand" @@ -11,20 +11,19 @@ import ( "os" "time" - "git.a71.su/Andrew71/pye/config" - "git.a71.su/Andrew71/pye/storage" "github.com/golang-jwt/jwt/v5" ) var ( - 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 MustLoadKey() { +func LoadKey() { // If the key doesn't exist, create it - if _, err := os.Stat(config.Cfg.KeyFile); errors.Is(err, os.ErrNotExist) { + if _, err := os.Stat(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 +33,7 @@ func MustLoadKey() { // Save key to disk km := x509.MarshalPKCS1PrivateKey(key) block := pem.Block{Bytes: km, Type: "RSA PRIVATE KEY"} - f, err := os.OpenFile(config.Cfg.KeyFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + f, err := os.OpenFile(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 +43,9 @@ func MustLoadKey() { slog.Error("error closing file", "error", err) os.Exit(1) } - slog.Info("generated new key", "file", config.Cfg.KeyFile) + slog.Info("generated new key") } else { - km, err := os.ReadFile(config.Cfg.KeyFile) + km, err := os.ReadFile(KeyFile) if err != nil { slog.Error("error reading key", "error", err) os.Exit(1) @@ -56,27 +55,23 @@ func MustLoadKey() { slog.Error("error parsing key", "error", err) os.Exit(1) } - slog.Info("loaded private key", "file", config.Cfg.KeyFile) + slog.Info("loaded private key") } } -func init() { - MustLoadKey() -} - -// PublicKey returns our public key as PEM block over http -func PublicKey(w http.ResponseWriter, r *http.Request) { +// publicKey returns our public key as PEM block +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(user storage.User) (string, error) { +func CreateJWT(usr User) (string, error) { t := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ "iss": "pye", - "uid": user.Uuid, - "sub": user.Email, + "uid": usr.Uuid, + "sub": usr.Email, "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour * 24 * 7).Unix(), }) @@ -90,8 +85,8 @@ func CreateJWT(user 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) (*jwt.Token, error) { - t, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { +func VerifyJWT(token string, publicKey []byte) bool { + _, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { key, err := jwt.ParseRSAPublicKeyFromPEM(publicKey) if err != nil { return nil, err @@ -101,5 +96,10 @@ func VerifyJWT(token string, publicKey []byte) (*jwt.Token, error) { } return key, nil }) - return t, err + slog.Info("Error check", "err", err) + return err == nil +} + +func init() { + LoadKey() } diff --git a/main.go b/main.go index 1d44e82..08969f7 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,23 @@ package main -import "git.a71.su/Andrew71/pye/cmd" +import ( + "fmt" + "net/http" +) func main() { - cmd.Run() + 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) } diff --git a/storage/sqlite/sqlite.go b/storage/sqlite/sqlite.go deleted file mode 100644 index 1a835d4..0000000 --- a/storage/sqlite/sqlite.go +++ /dev/null @@ -1,79 +0,0 @@ -package sqlite - -import ( - "database/sql" - "errors" - "log/slog" - "os" - - "git.a71.su/Andrew71/pye/storage" - _ "github.com/mattn/go-sqlite3" -) - -const create string = ` - CREATE TABLE "users" ( - "uuid" TEXT NOT NULL UNIQUE, - "email" TEXT NOT NULL UNIQUE, - "password" TEXT NOT NULL, - PRIMARY KEY("uuid") - );` - -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(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) - os.Exit(1) - } - db, err := sql.Open("sqlite3", dataFile) - if err != nil { - 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", "file", dataFile) - return SQLiteStorage{db} -} diff --git a/storage/storage.go b/storage/storage.go deleted file mode 100644 index dcee5fe..0000000 --- a/storage/storage.go +++ /dev/null @@ -1,8 +0,0 @@ -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/storage/user.go b/user.go similarity index 85% rename from storage/user.go rename to user.go index 5c34a5d..abbfb7a 100644 --- a/storage/user.go +++ b/user.go @@ -1,8 +1,6 @@ -package storage +package main import ( - "log/slog" - "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -21,8 +19,7 @@ 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