commit 6424512612e6d7015697686ad9cfd97a4515f975 Author: Magnus Ă…hall Date: Thu Jan 4 20:19:47 2024 +0100 Initial commit diff --git a/config/pkg.go b/config/pkg.go new file mode 100644 index 0000000..9f224d8 --- /dev/null +++ b/config/pkg.go @@ -0,0 +1,55 @@ +package config + +import ( + // External + "gopkg.in/yaml.v3" + + // Standard + "errors" + "os" +) + +type DatabaseDetails struct { + Host string + Port int + Name string + Username string + Password string +} + + +type Config struct { + Network struct { + Address string + Port int + } + + Websocket struct { + Domains []string + } + + Database DatabaseDetails + + Session struct { + DaysValid int + } +} + +func New(filename string) (config Config, err error) { + var rawConfigData []byte + rawConfigData, err = os.ReadFile(filename) + if err != nil { + return + } + + err = yaml.Unmarshal(rawConfigData, &config) + if err != nil { + return + } + + if config.Session.DaysValid == 0 { + err = errors.New("Configuration: session.daysvalid needs to be higher than 0.") + } + + return +} diff --git a/database/pkg.go b/database/pkg.go new file mode 100644 index 0000000..00d585e --- /dev/null +++ b/database/pkg.go @@ -0,0 +1,244 @@ +package database + +import ( + // External + "git.gibonuddevalla.se/go/dbschema" + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + + // Internal + "git.gibonuddevalla.se/go/webservice/config" + "git.gibonuddevalla.se/go/webservice/session" + + // Standard + "database/sql" + "fmt" + "log/slog" +) + +type SqlProvider func(string, int) ([]byte, bool) + +type T struct { + cfg config.DatabaseDetails + Conn *sqlx.DB + logger *slog.Logger + + sqlProvider SqlProvider + logProvider func(string, string) +} + +func New(cfg config.DatabaseDetails) (db *T) { // {{{ + db = new(T) + db.cfg = cfg + db.logProvider = db.defaultLogProvider + return +} // }}} + +func (db *T) SetLogger(l *slog.Logger) { // {{{ + db.logger = l +} // }}} +func (db *T) SetSQLProvider(fn func(string, int) ([]byte, bool)) { // {{{ + db.sqlProvider = fn +} // }}} +func (db *T) SetLogProvider(fn func(string, string)) { // {{{ + db.logProvider = fn +} // }}} + +func (db *T) defaultLogProvider(category, msg string) { // {{{ + db.logger.Info("database", category, msg) +} // }}} +func webserviceSQLProvider(dbname string, version int) ([]byte, bool) { // {{{ + sql := map[int]string{ + 1: ` + CREATE TABLE _webservice.user ( + id serial NOT NULL, + "name" varchar NOT NULL, + "username" varchar NOT NULL, + "password" char(96) NOT NULL, + last_login timetz NOT NULL DEFAULT '1970-01-01 00:00:00', + CONSTRAINT user_pk PRIMARY KEY (id), + CONSTRAINT user_un UNIQUE (username) + ); + + CREATE TABLE "_webservice"."session" ( + id serial NOT NULL, + user_id int4 NULL, + "uuid" char(36) NOT NULL, + created time with time zone NOT NULL DEFAULT NOW(), + CONSTRAINT session_pk PRIMARY KEY (id), + CONSTRAINT session_un UNIQUE ("uuid"), + CONSTRAINT session_user_fk FOREIGN KEY (user_id) REFERENCES "_webservice"."user"(id) ON DELETE CASCADE ON UPDATE CASCADE + ); + + CREATE EXTENSION IF NOT EXISTS pgcrypto SCHEMA _webservice; + + CREATE FUNCTION _webservice.password_hash(salt_hex char(32), pass bytea) + RETURNS char(96) + LANGUAGE plpgsql + AS + $$ + BEGIN + RETURN ( + SELECT + salt_hex || + encode( + sha256( + decode(salt_hex, 'hex') || /* salt in binary */ + pass /* password */ + ), + 'hex' + ) + ); + END; + $$; + `, + 2: ` + ALTER TABLE _webservice.session ADD last_used timetz NOT NULL DEFAULT NOW(); + `, + } + + statement, found := sql[version] + return []byte(statement), found +} // }}} + +func (db *T) Upgrade() (err error) { // {{{ + upgrader := dbschema.NewUpgrader("_webservice") + upgrader.SetSqlCallback(webserviceSQLProvider) + upgrader.SetLogCallback(db.logProvider) + if err = upgrader.AddDatabase( + db.cfg.Host, + db.cfg.Port, + db.cfg.Name, + db.cfg.Username, + db.cfg.Password, + ); err != nil { + return + } + + err = upgrader.Run() + if err != nil { + return + } + + upgrader = dbschema.NewUpgrader("_db") + upgrader.SetSqlCallback(db.sqlProvider) + upgrader.SetLogCallback(db.logProvider) + if err = upgrader.AddDatabase( + db.cfg.Host, + db.cfg.Port, + db.cfg.Name, + db.cfg.Username, + db.cfg.Password, + ); err != nil { + return + } + + err = upgrader.Run() + return +} // }}} +func (db *T) Connect() (err error) { // {{{ + db.logger.Info("database", "host", db.cfg.Host, "port", db.cfg.Port, "name", db.cfg.Name) + + dbConn := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + db.cfg.Host, + db.cfg.Port, + db.cfg.Username, + db.cfg.Password, + db.cfg.Name, + ) + + if db.Conn, err = sqlx.Connect("postgres", dbConn); err != nil { + return + } + + return +} // }}} +func (db *T) Authenticate(username, password string) (authenticated bool, userID int, err error) { // {{{ + var rows *sql.Rows + if rows, err = db.Conn.Query(` + SELECT id + FROM _webservice.user + WHERE + username = $1 AND + password = _webservice.password_hash(SUBSTRING(password FROM 1 FOR 32), $2::bytea) + `, + username, + password, + ); err != nil { + return + } + defer rows.Close() + + if rows.Next() { + rows.Scan(&userID) + authenticated = userID > 0 + } + return +} // }}} +func (db *T) NewSession(uuid string) (err error) { // {{{ + _, err = db.Conn.Exec("INSERT INTO _webservice.session(uuid) VALUES($1)", uuid) + return +} // }}} +func (db *T) RetrieveSession(uuid string) (sess *session.T, err error) {// {{{ + var rows *sqlx.Rows + rows, err = db.Conn.Queryx(` + WITH session_data AS ( + UPDATE _webservice.session + SET + last_used=NOW() + WHERE + uuid=$1 + RETURNING + uuid, created, last_used, user_id + ) + SELECT + sd.uuid, sd.created, sd.last_used, + COALESCE(u.username, '') AS username, + COALESCE(u.name, '') AS name + FROM session_data sd + LEFT JOIN _webservice.user u ON sd.user_id = u.id + `, + uuid, + ) + if err != nil { + return + } + defer rows.Close() + + for rows.Next() { + sess = new(session.T) + err = rows.StructScan(sess) + } + return +}// }}} +func (db *T) SetSessionUser(uuid string, userID int) (err error) { // {{{ + _, err = db.Conn.Exec("UPDATE _webservice.session SET user_id=$1 WHERE uuid=$2", userID, uuid) + if err != nil { + return + } + return +} // }}} +func (db *T) CreateUser(username, password, name string) (err error) {// {{{ + _, err = db.Conn.Exec(` + INSERT INTO _webservice.user(username, password, name) + VALUES( + $1, + _webservice.password_hash( + /* salt in hex */ + ENCODE(_webservice.gen_random_bytes(16), 'hex'), + + /* password */ + $2::bytea + ), + $3 + ) + `, + username, + password, + name, + ) + return +}// }}} + +// vim: foldmethod=marker diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d912f97 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module git.gibonuddevalla.se/go/webservice + +go 1.21.0 + +require ( + git.gibonuddevalla.se/go/dbschema v1.3.0 + github.com/google/uuid v1.5.0 + github.com/jmoiron/sqlx v1.3.5 + github.com/lib/pq v1.10.9 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fe5e738 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +git.gibonuddevalla.se/go/dbschema v1.2.0 h1:VhHFfkn/4UnlGy2Ax35Po8vb8E/x6DggtvNUKlGGQyY= +git.gibonuddevalla.se/go/dbschema v1.2.0/go.mod h1:BNw3q/574nXbGoeWyK+tLhRfggVkw2j2aXZzrBKC3ig= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg.go b/pkg.go new file mode 100644 index 0000000..ee97fa2 --- /dev/null +++ b/pkg.go @@ -0,0 +1,217 @@ +/* +The webservice package is used to provide a webservice with sessions: + + func sqlProvider(dbname string, version int) (sql []byte, found bool) { + var err error + sql, err = embeddedSQL.ReadFile(fmt.Sprintf("sql/%05d.sql", version)) + if err != nil { + return + } + found = true + return + } + + service, err := webservice.New("/etc/some/webservice.yaml") + if err != nil { + logger.Error("application", "error", err) + os.Exit(1) + } + + service.SetDatabase(sqlProvider) + service.SetAuthenticationHandler(authenticate) + service.SetAuthorizationHandler(authorize) + service.Register("/foo", true, true, foo) + service.Register("/bar", true, false, bar) + err = service.Start() + if err != nil { + logger.Error("webserver", "error", err) + os.Exit(1) + } +*/ +package webservice + +import ( + // Internal + "git.gibonuddevalla.se/go/webservice/config" + "git.gibonuddevalla.se/go/webservice/database" + "git.gibonuddevalla.se/go/webservice/session" + + // Standard + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" +) + +const VERSION = "v0.1.0" + +type HttpHandler func(http.ResponseWriter, *http.Request) + +type ErrorHandler func(err error, w http.ResponseWriter) +type ServiceError struct { + OK bool + Error string +} + +type Service struct { + logger *slog.Logger + sessions map[string]*session.T + config config.Config + Db *database.T + + errorHandler ErrorHandler + authenticationHandler AuthenticationHandler + authorizationHandler AuthorizationHandler +} + +type ServiceHandler func(http.ResponseWriter, *http.Request, *session.T) + +func New(configFilename string) (service *Service, err error) { // {{{ + service = new(Service) + + service.config, err = config.New(configFilename) + if err != nil { + return + } + + opts := slog.HandlerOptions{} + service.logger = slog.New(slog.NewJSONHandler(os.Stdout, &opts)) + service.sessions = make(map[string]*session.T, 128) + service.errorHandler = service.defaultErrorHandler + service.authenticationHandler = service.defaultAuthenticationHandler + service.authorizationHandler = service.defaultAuthorizationHandler + + service.Register("/_session/new", false, false, service.sessionNew) + service.Register("/_session/authenticate", true, false, service.sessionAuthenticate) + + return +} // }}} + +func (service *Service) defaultAuthenticationHandler(req AuthenticationRequest, alreadyAuthenticated bool) (resp AuthenticationResponse, err error) { // {{{ + resp.Authenticated = alreadyAuthenticated + service.logger.Info("webservice", "op", "authentication", "request", req, "authenticated", resp.Authenticated) + return +} // }}} +func (service *Service) defaultAuthorizationHandler(sess *session.T, r *http.Request) (resp bool, err error) { // {{{ + service.logger.Error("webservice", "op", "authorization", "session", sess.UUID, "request", r, "authorized", false) + return +} // }}} +func (service *Service) defaultErrorHandler(err error, w http.ResponseWriter) { // {{{ + service.logger.Error("webservice", "error", err) + errMsg := ServiceError{} + errMsg.OK = false + errMsg.Error = err.Error() + errJSON, _ := json.Marshal(errMsg) + w.Write(errJSON) +} // }}} + +func (service *Service) SetLogger(logger *slog.Logger) { // {{{ + service.logger = logger +} // }}} +func (service *Service) SetErrorHandler(h ErrorHandler) { // {{{ + service.errorHandler = h +} // }}} +func (service *Service) SetAuthenticationHandler(h AuthenticationHandler) { // {{{ + service.authenticationHandler = h +} // }}} +func (service *Service) SetAuthorizationHandler(h AuthorizationHandler) { // {{{ + service.authorizationHandler = h +} // }}} + +func (service *Service) SetDatabase(sqlProv database.SqlProvider) { // {{{ + service.Db = database.New(service.config.Database) + service.Db.SetLogger(service.logger) + service.Db.SetSQLProvider(sqlProv) + return +} // }}} +func (service *Service) Register(path string, requireSession, requireAuthentication bool, handler ServiceHandler) { // {{{ + http.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + var session *session.T + var found bool + var authorized bool + var err error + + if requireAuthentication && !requireSession { + requireSession = true + } + + if requireSession { + headerSessionUUID, err := sessionUUID(r) + if err != nil { + service.errorHandler(fmt.Errorf("Header X-Session-ID missing"), w) + return + } + + session, found = service.sessionRetrieve(headerSessionUUID) + if !found { + service.errorHandler(fmt.Errorf("Session '%s' not found", headerSessionUUID), w) + return + } + } + + if requireAuthentication { + if !session.Authenticated { + service.errorHandler(fmt.Errorf("Session '%s' not authenticated", session.UUID), w) + return + } + + authorized, err = service.authorizationHandler(session, r) + if err != nil { + service.errorHandler(err, w) + return + } + if !authorized { + service.errorHandler(fmt.Errorf("Session '%s' not authorized for %s", session.UUID, r.URL.String()), w) + return + } + } + + handler(w, r, session) + }) +} // }}} +func (service *Service) InitDatabaseConnection() (err error) { // {{{ + err = service.Db.Upgrade() + if err != nil { + return + } + + err = service.Db.Connect() + if err != nil { + return + } + return +} // }}} +func (service *Service) CreateUser(username, password, name string) (err error) { // {{{ + if service.Db != nil { + err = service.InitDatabaseConnection() + if err != nil { + return + } + } + + err = service.Db.CreateUser(username, password, name) + return +} // }}} +func (service *Service) Start() (err error) { // {{{ + if service.Db != nil { + err = service.InitDatabaseConnection() + if err != nil { + return + } + } + + listen := fmt.Sprintf("%s:%d", service.config.Network.Address, service.config.Network.Port) + service.logger.Info("webserver", "listen", listen) + err = http.ListenAndServe(listen, nil) + return +} // }}} + +func sessionUUID(r *http.Request) (string, error) { // {{{ + headers := r.Header["X-Session-Id"] + if len(headers) > 0 { + return headers[0], nil + } + return "", errors.New("Invalid session") +} // }}} diff --git a/session.go b/session.go new file mode 100644 index 0000000..3e3d65b --- /dev/null +++ b/session.go @@ -0,0 +1,136 @@ +package webservice + +import ( + // External + "github.com/google/uuid" + + // Internal + "git.gibonuddevalla.se/go/webservice/session" + + // Standard + "encoding/json" + "io" + "net/http" + "time" +) + +type AuthenticationRequest struct { + Username string + Password string +} + +type AuthenticationResponse struct { + Authenticated bool +} + +type AuthenticationHandler func(AuthenticationRequest, bool) (AuthenticationResponse, error) +type AuthorizationHandler func(*session.T, *http.Request) (bool, error) + +func (service *Service) sessionNew(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ + var session session.T + var found bool + var err error + + for { + session.UUID = uuid.NewString() + if service.Db == nil { + if _, found = service.sessions[session.UUID]; found { + continue + } + + session.Authenticated = false + session.Created = time.Now() + service.sessions[session.UUID] = &session + break + + } else { + if _, found = service.sessionRetrieve(session.UUID); found { + continue + } + + err = service.Db.NewSession(session.UUID) + if err != nil { + service.errorHandler(err, w) + return + } + break + } + + } + + respJSON, _ := json.Marshal( + struct { + OK bool + UUID string + }{ + true, + session.UUID, + }, + ) + + w.Write(respJSON) +} // }}} +func (service *Service) sessionAuthenticate(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ + var authenticated bool + var authResponse AuthenticationResponse + var err error + reqBody, _ := io.ReadAll(r.Body) + + // Username and password are provided in this JSON authentication request. + var authRequest AuthenticationRequest + err = json.Unmarshal(reqBody, &authRequest) + if err != nil { + service.errorHandler(err, w) + return + } + + // Authenticate against webservice user table if using a database. + if service.Db != nil { + var userID int + authenticated, userID, err = service.Db.Authenticate(authRequest.Username, authRequest.Password) + if err != nil { + service.errorHandler(err, w) + return + } + + if authenticated && userID > 0 { + err = service.Db.SetSessionUser(sess.UUID, userID) + if err != nil { + service.errorHandler(err, w) + return + } + } + + } + + // The authentication handler is provided with the authenticated response of the possible database authentication, + // and given a chance to override it. + authResponse, err = service.authenticationHandler(authRequest, authenticated) + if err != nil { + service.errorHandler(err, w) + return + } + + sess.Authenticated = authResponse.Authenticated + + authResp, _ := json.Marshal(authResponse) + w.Write(authResp) +} // }}} +func (service *Service) sessionRetrieve(uuid string) (session *session.T, found bool) { // {{{ + var err error + + if service.Db == nil { + session, found = service.sessions[uuid] + return + } + + session, err = service.Db.RetrieveSession(uuid) + if err != nil { + service.logger.Error("session", "error", err) + session = nil + return + } + + found = (session != nil) + return +} // }}} diff --git a/session/pkg.go b/session/pkg.go new file mode 100644 index 0000000..2605c77 --- /dev/null +++ b/session/pkg.go @@ -0,0 +1,16 @@ +package session + +import ( + // Standard + "time" +) + +type T struct { + UUID string + Created time.Time + LastUsed time.Time `db:"last_used"` + Authenticated bool + + Username string + Name string +}