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} }