Refactor everything again

This commit is contained in:
Andrew-71 2024-10-13 21:03:44 +03:00
parent 7fdb0bf0f4
commit 96c369e4b1
14 changed files with 72 additions and 53 deletions

40
internal/app/find.go Normal file
View file

@ -0,0 +1,40 @@
package app
import (
"fmt"
"git.a71.su/Andrew71/pye/internal/models/user"
"git.a71.su/Andrew71/pye/internal/storage"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(findUserCmd)
}
var findUserCmd = &cobra.Command{
Use: "find <uuid/email> <query>",
Short: "Find a user",
Long: `Find information about a user from their UUID or email`,
Args: cobra.ExactArgs(2),
Run: findUser,
}
func findUser(cmd *cobra.Command, args []string) {
var user user.User
var ok bool
if args[0] == "email" {
user, ok = storage.Data.ByEmail(args[1])
} else if args[0] == "uuid" {
user, ok = storage.Data.ById(args[1])
} else {
fmt.Println("Expected email or uuid")
return
}
if !ok {
fmt.Println("User not found")
} else {
fmt.Printf("Information for user:\nuuid\t- %s\nemail\t- %s\nhash\t- %s\n",
user.Uuid, user.Email, user.Hash)
}
}

50
internal/app/root.go Normal file
View file

@ -0,0 +1,50 @@
package app
import (
"fmt"
"os"
"git.a71.su/Andrew71/pye/internal/auth"
"git.a71.su/Andrew71/pye/internal/config"
"git.a71.su/Andrew71/pye/internal/logging"
"git.a71.su/Andrew71/pye/internal/storage"
"git.a71.su/Andrew71/pye/internal/storage/sqlite"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "pye",
Short: "Pye is a simple JWT system",
Long: `A bare-bones authentication system with RS256`,
}
var (
cfgFile string
cfgDb string
debugMode *bool
)
func initConfig() {
logging.Load(*debugMode)
config.MustLoad(cfgFile)
if cfgDb != "" {
config.Cfg.SQLiteFile = cfgDb
}
auth.MustLoadKey()
storage.Data = sqlite.MustLoad(config.Cfg.SQLiteFile)
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "config.json", "config file")
rootCmd.PersistentFlags().StringVar(&cfgDb, "db", "", "database to use")
debugMode = rootCmd.PersistentFlags().BoolP("debug", "d", false, "enable debug mode")
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}

48
internal/app/serve.go Normal file
View file

@ -0,0 +1,48 @@
package app
import (
"log/slog"
"net/http"
"strconv"
"git.a71.su/Andrew71/pye/internal/auth"
"git.a71.su/Andrew71/pye/internal/config"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/spf13/cobra"
)
var port int
func init() {
serveCmd.Flags().IntVarP(&port, "port", "p", 0, "port to use")
rootCmd.AddCommand(serveCmd)
}
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Start JWT service",
Long: `Start a simple authentication service`,
Run: serveAuth,
}
func serveAuth(cmd *cobra.Command, args []string) {
if port == 0 {
port = config.Cfg.Port
}
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Logger, middleware.CleanPath, middleware.StripSlashes)
r.Get("/pem", auth.ServePublicKey)
r.Post("/register", auth.Register)
r.Post("/login", auth.Login)
// Note: likely temporary, possibly to be replaced by a fake "frontend"
r.Get("/register", auth.Register)
r.Get("/login", auth.Login)
slog.Info("🪐 pye started", "port", port)
http.ListenAndServe(":"+strconv.Itoa(port), r)
}

58
internal/app/verify.go Normal file
View file

@ -0,0 +1,58 @@
package app
import (
"fmt"
"log/slog"
"os"
"git.a71.su/Andrew71/pye/internal/auth"
"github.com/golang-jwt/jwt/v5"
"github.com/spf13/cobra"
)
var (
verifyToken string
verifyFile string
)
func init() {
verifyCmd.Flags().StringVarP(&verifyToken, "token", "t", "", "token to verify")
verifyCmd.MarkFlagRequired("token")
verifyCmd.Flags().StringVarP(&verifyFile, "file", "f", "", "PEM file to use")
rootCmd.AddCommand(verifyCmd)
}
var verifyCmd = &cobra.Command{
Use: "verify",
Short: "Verify a JWT token",
Long: `Pass a JWT token (and optionally a path to a PEM-formatted file with the public key)
to verify whether it is valid.`,
Run: verifyFunc,
}
func verifyFunc(cmd *cobra.Command, args []string) {
if verifyToken == "" {
fmt.Println("Empty token supplied!")
return
}
var t *jwt.Token
var err error
if verifyFile == "" {
fmt.Println("No PEM file supplied, assuming local")
t, err = auth.VerifyLocal(verifyToken)
} else {
key, err_k := os.ReadFile(verifyFile)
if err_k != nil {
slog.Error("error reading file", "error", err, "file", verifyFile)
return
}
t, err = auth.Verify(verifyToken, key)
}
slog.Debug("result", "token", t, "error", err, "ok", err == nil)
if err == nil {
fmt.Println("Token valid!")
} else {
fmt.Println("Token invalid!", err)
}
}

82
internal/auth/auth.go Normal file
View file

@ -0,0 +1,82 @@
package auth
import (
"errors"
"log/slog"
"net/http"
"net/mail"
"strings"
"git.a71.su/Andrew71/pye/internal/storage"
)
func validEmail(email string) bool {
_, err := mail.ParseAddress(email)
return err == nil
}
func validPass(pass string) bool {
// TODO: Obviously, we *might* want something more sophisticated here
return len(pass) >= 8
}
// Register creates a new user with credentials provided through Basic Auth
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)) {
slog.Debug("outcome",
"valid_email", validEmail(email),
"valid_pass", validPass(password),
"taken_email", storage.Data.Taken(email))
http.Error(w, "invalid auth credentials", http.StatusBadRequest)
return
}
err := storage.Data.Add(email, password)
if err != nil {
if errors.Is(err, storage.ErrExist) {
http.Error(w, "invalid auth credentials", http.StatusBadRequest)
} else {
http.Error(w, "error adding a new user", http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusCreated)
w.Write([]byte("user created"))
return
}
// No email and password was provided
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "This API requires authorization", http.StatusUnauthorized)
}
// Login returns JWT for a registered user through Basic Auth
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 := storage.Data.ByEmail(email)
if !ok || !user.Fits(password) {
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "you did something wrong", http.StatusUnauthorized)
return
}
token, err := Create(user)
if err != nil {
http.Error(w, "error creating jwt", http.StatusInternalServerError)
return
}
w.Write([]byte(token))
return
}
// No email and password was provided
w.Header().Set("WWW-Authenticate", `Basic realm="restricted", charset="UTF-8"`)
http.Error(w, "This API requires authorization", http.StatusUnauthorized)
}

107
internal/auth/jwt.go Normal file
View file

@ -0,0 +1,107 @@
package auth
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"log/slog"
"net/http"
"os"
"time"
"git.a71.su/Andrew71/pye/internal/config"
"git.a71.su/Andrew71/pye/internal/models/user"
"github.com/golang-jwt/jwt/v5"
)
var key *rsa.PrivateKey
// LoadKey attempts to load a private RS256 key from file.
// 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(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)
os.Exit(1)
}
// 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)
if err != nil {
slog.Error("error opening/creating file", "error", err)
os.Exit(1)
}
f.Write(pem.EncodeToMemory(&block))
if err := f.Close(); err != nil {
slog.Error("error closing file", "error", err)
os.Exit(1)
}
slog.Debug("generated new key", "file", config.Cfg.KeyFile)
} else {
km, err := os.ReadFile(config.Cfg.KeyFile)
if err != nil {
slog.Error("error reading key", "error", err)
os.Exit(1)
}
key, err = jwt.ParseRSAPrivateKeyFromPEM(km)
if err != nil {
slog.Error("error parsing key", "error", err)
os.Exit(1)
}
slog.Debug("loaded private key", "file", config.Cfg.KeyFile)
}
}
// ServePublicKey returns our public key as PEM block over HTTP
func ServePublicKey(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)
}
// Create creates a JSON Web Token that expires after a week
func Create(user user.User) (token string, err error) {
t := jwt.NewWithClaims(jwt.SigningMethodRS256,
jwt.MapClaims{
"iss": "pye",
"uid": user.Uuid,
"sub": user.Email,
"iat": time.Now().Unix(),
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(),
})
token, err = t.SignedString(key)
if err != nil {
slog.Error("error creating JWT", "error", err)
return "", err
}
return
}
// Verify receives a JWT and PEM-encoded public key,
// then returns whether the token is valid
func Verify(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
}
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, err
}
return key, nil
})
return t, err
}
// VerifyLocal calls Verify with public key set to current local one
func VerifyLocal(token string) (*jwt.Token, error) {
key_marshalled := x509.MarshalPKCS1PublicKey(&key.PublicKey)
block := pem.Block{Bytes: key_marshalled, Type: "RSA PUBLIC KEY"}
return Verify(token, pem.EncodeToMemory(&block))
}

54
internal/config/config.go Normal file
View file

@ -0,0 +1,54 @@
package config
import (
"encoding/json"
"log/slog"
"os"
)
// Config stores app configuration
type Config struct {
Port int `json:"port"`
KeyFile string `json:"key-file"`
SQLiteFile string `json:"sqlite-file"`
LogToFile bool `json:"log-to-file"`
LogFile string `json:"log-file"`
}
var DefaultConfig = Config{
Port: 7102,
KeyFile: "private.key",
SQLiteFile: "data.db",
LogToFile: false,
LogFile: "pye.log",
}
var (
DefaultLocation = "config.json"
Cfg Config
)
// Load parses a JSON-formatted configuration file
func load(filename string) (Config, error) {
data, err := os.ReadFile(filename)
if err != nil {
return Config{}, err
}
conf := DefaultConfig
err = json.Unmarshal(data, &conf)
if err != nil {
return Config{}, err
}
slog.Debug("Loaded config", "file", filename, "config", conf)
return conf, nil
}
// MustLoad handles initial configuration loading
func MustLoad(filename string) {
conf, err := load(filename)
if err != nil {
slog.Error("error initially loading config", "error", err)
os.Exit(1)
}
Cfg = conf
}

36
internal/logging/log.go Normal file
View file

@ -0,0 +1,36 @@
package logging
import (
"io"
"log"
"log/slog"
"os"
"git.a71.su/Andrew71/pye/internal/config"
"github.com/go-chi/chi/middleware"
)
// Load makes slog output to both os.Stdout and a file if needed, and sets slog.LevelDebug if enabled.
func Load(debugMode bool) {
var w io.Writer
if config.Cfg.LogToFile {
f, err := os.OpenFile(config.Cfg.LogFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
slog.Error("error opening log file, logging to stdout only", "path", config.Cfg.LogFile, "error", err)
return
}
// No defer f.Close() because that breaks the MultiWriter
w = io.MultiWriter(f, os.Stdout)
} else {
w = os.Stdout
}
// Make slog use intended format
var opts *slog.HandlerOptions
if debugMode {
opts = &slog.HandlerOptions{Level: slog.LevelDebug}
}
slog.SetDefault(slog.New(slog.NewTextHandler(w, opts)))
middleware.DefaultLogger = middleware.RequestLogger(&middleware.DefaultLogFormatter{Logger: log.Default(), NoColor: true})
slog.Debug("debug mode active")
}

View file

@ -0,0 +1,30 @@
package user
import (
"log/slog"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
type User struct {
Uuid uuid.UUID
Email string
Hash []byte // bcrypt hash of password
}
// Fits checks whether the password is correct
func (u User) Fits(password string) bool {
err := bcrypt.CompareHashAndPassword(u.Hash, []byte(password))
return err == nil
}
// New Creates a new User
func New(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
}

View file

@ -0,0 +1,92 @@
package sqlite
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"os"
"git.a71.su/Andrew71/pye/internal/models/user"
"git.a71.su/Andrew71/pye/internal/storage"
sqlite "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")
);`
// SQLiteStorage is a Storage implementation with SQLite3
type SQLiteStorage struct {
db *sql.DB
}
func (s SQLiteStorage) Add(email, password string) error {
user, err := user.New(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 {
e, ok := err.(sqlite.Error)
if ok && errors.Is(e.ExtendedCode, sqlite.ErrConstraintUnique) {
// Return a standard error if the user already exists
slog.Info("can't add user because email already taken", "user", user)
return fmt.Errorf("%w (%s)", storage.ErrExist, user.Email)
}
slog.Error("error adding user to database", "error", err, "user", user)
return err
}
return nil
}
func (s SQLiteStorage) ById(uuid string) (user.User, bool) {
row := s.db.QueryRow("select * from users where uuid = $1", uuid)
user := user.User{}
err := row.Scan(&user.Uuid, &user.Email, &user.Hash)
return user, err == nil
}
func (s SQLiteStorage) ByEmail(email string) (user.User, bool) {
row := s.db.QueryRow("select * from users where email = $1", email)
user := user.User{}
err := row.Scan(&user.Uuid, &user.Email, &user.Hash)
return user, err == nil
}
func (s SQLiteStorage) Taken(email string) bool {
_, ok := s.ByEmail(email)
return ok
}
func MustLoad(dataFile string) SQLiteStorage {
if _, err := os.Stat(dataFile); errors.Is(err, os.ErrNotExist) {
os.Create(dataFile)
slog.Debug("created sqlite3 database file", "file", dataFile)
}
db, err := sql.Open("sqlite3", dataFile)
if err != nil {
slog.Error("error opening sqlite3 database", "error", err)
os.Exit(1)
}
statement, err := db.Prepare(create)
if err != nil {
if err.Error() != "table \"users\" already exists" {
slog.Error("error initialising sqlite3 database table", "error", err)
os.Exit(1)
}
} else {
statement.Exec()
}
slog.Debug("loaded database", "file", dataFile)
return SQLiteStorage{db}
}

View file

@ -0,0 +1,21 @@
package storage
import (
"errors"
"git.a71.su/Andrew71/pye/internal/models/user"
)
var ErrExist = errors.New("user already exists")
// Storage is an interface for arbitrary storage
type Storage interface {
Add(email, password string) error // Add inserts a user into data
ById(uuid string) (user.User, bool) // ById retrieves a user by their UUID
ByEmail(email string) (user.User, bool) // ByEmail retrieves a user by their email
Taken(email string) bool // Taken checks whether an email is taken
}
// Data stores active information for the app.
// It should be populated on startup
var Data Storage