Lindenii Project Forge
Login

server

Vireo IdP server

Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!

Commit info
ID
45cc2876911e012b1752aec29af83ad05c0de44e
Author
Author date
Mon, 19 Feb 2024 14:29:56 +0100
Committer
Committer date
Mon, 19 Feb 2024 14:30:23 +0100
Actions
Add UI to revoke access tokens

Closes: https://todo.sr.ht/~emersion/sinwon/15
package main

import (
	"fmt"
	"net/http"
	"net/url"
	"strings"

	"github.com/go-chi/chi/v5"
)

func manageClient(w http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	db := dbFromContext(ctx)
	tpl := templateFromContext(ctx)

	loginToken := loginTokenFromContext(ctx)
	if loginToken == nil {
		http.Redirect(w, req, "/login", http.StatusFound)
		return
	}

	me, err := db.FetchUser(ctx, loginToken.User)
	if err != nil {
		httpError(w, err)
		return
	} else if !me.Admin {
		http.Error(w, "Access denied", http.StatusForbidden)
		return
	}

	client := &Client{Owner: loginToken.User}
	if idStr := chi.URLParam(req, "id"); idStr != "" {
		id, err := ParseID[*Client](idStr)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}

		client, err = db.FetchClient(ctx, id)
		if err != nil {
			httpError(w, err)
			return
		}
	}

	if req.Method != http.MethodPost {
		data := struct {
			Client *Client
		}{
			Client: client,
		}
		if err := tpl.ExecuteTemplate(w, "manage-client.html", &data); err != nil {
			panic(err)
		}
		return
	}

	_ = req.ParseForm()
	if _, ok := req.PostForm["delete"]; ok {
		if err := db.DeleteClient(ctx, client.ID); err != nil {
			httpError(w, err)
			return
		}
		http.Redirect(w, req, "/", http.StatusFound)
		return
	}

	client.ClientName = req.PostFormValue("client_name")
	client.ClientURI = req.PostFormValue("client_uri")
	client.RedirectURIs = req.PostFormValue("redirect_uris")
	isPublic := req.PostFormValue("client_type") == "public"

	for _, s := range strings.Split(client.RedirectURIs, "\n") {
		if s == "" {
			continue
		}
		u, err := url.Parse(s)
		if err != nil {
			// TODO: nicer error message
			http.Error(w, fmt.Sprintf("Invalid redirect URI %q: %v", s, err), http.StatusBadRequest)
			return
		}
		switch u.Scheme {
		case "https":
			// ok
		case "http":
			if u.Host != "localhost" {
				http.Error(w, "Only http://localhost is allowed for insecure HTTP URIs", http.StatusBadRequest)
				return
			}
		default:
			if !strings.Contains(u.Scheme, ".") {
				http.Error(w, "Only private-use URIs referring to domain names are allowed", http.StatusBadRequest)
				return
			}
		}
	}

	var clientSecret string
	if client.ID == 0 {
		clientSecret, err = client.Generate(isPublic)
		if err != nil {
			httpError(w, err)
			return
		}
	}

	if err := db.StoreClient(ctx, client); err != nil {
		httpError(w, err)
		return
	}

	if clientSecret == "" {
		http.Redirect(w, req, "/", http.StatusFound)
		return
	}

	data := struct {
		ClientID     string
		ClientSecret string
	}{
		ClientID:     client.ClientID,
		ClientSecret: clientSecret,
	}
	if err := tpl.ExecuteTemplate(w, "client-secret.html", &data); err != nil {
		panic(err)
	}
}

func revokeClient(w http.ResponseWriter, req *http.Request) {
	ctx := req.Context()
	db := dbFromContext(ctx)

	id, err := ParseID[*Client](chi.URLParam(req, "id"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	loginToken := loginTokenFromContext(ctx)
	if loginToken == nil {
		http.Redirect(w, req, "/login", http.StatusFound)
		return
	}

	if err := db.RevokeAccessTokens(ctx, id, loginToken.User); err != nil {
		httpError(w, err)
		return
	}

	http.Redirect(w, req, "/", http.StatusFound)
}
package main

import (
	"context"
	"database/sql"
	_ "embed"
	"fmt"
	"time"

	"github.com/mattn/go-sqlite3"
)

//go:embed schema.sql
var schema string

var errNoDBRows = sql.ErrNoRows

type DB struct {
	db *sql.DB
}

func openDB(filename string) (*DB, error) {
	sqlDB, err := sql.Open("sqlite3", filename)
	if err != nil {
		return nil, err
	}

	db := &DB{sqlDB}
	if err := db.init(context.TODO()); err != nil {
		db.Close()
		return nil, err
	}

	return db, nil
}

func (db *DB) init(ctx context.Context) error {
	var n int
	if err := db.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_schema").Scan(&n); err != nil {
		return err
	} else if n != 0 {
		return nil
	}

	if _, err := db.db.ExecContext(ctx, schema); err != nil {
		return err
	}

	// TODO: drop this
	defaultUser := User{Username: "root"}
	if err := defaultUser.SetPassword("root"); err != nil {
		return err
	}
	return db.StoreUser(ctx, &defaultUser)
}

func (db *DB) Close() error {
	return db.db.Close()
}

func (db *DB) FetchUser(ctx context.Context, id ID[*User]) (*User, error) {
	rows, err := db.db.QueryContext(ctx, "SELECT * FROM User WHERE id = ?", id)
	if err != nil {
		return nil, err
	}
	var user User
	err = scanRow(&user, rows)
	return &user, err
}

func (db *DB) FetchUserByUsername(ctx context.Context, username string) (*User, error) {
	rows, err := db.db.QueryContext(ctx, "SELECT * FROM User WHERE username = ?", username)
	if err != nil {
		return nil, err
	}
	var user User
	err = scanRow(&user, rows)
	return &user, err
}

func (db *DB) StoreUser(ctx context.Context, user *User) error {
	return db.db.QueryRowContext(ctx, `
		INSERT INTO User(id, username, password_hash)
		VALUES (:id, :username, :password_hash)
		ON CONFLICT(id) DO UPDATE SET
			username = :username,
			password_hash = :password_hash
		RETURNING id
	`, entityArgs(user)...).Scan(&user.ID)
}

func (db *DB) ListUsers(ctx context.Context) ([]User, error) {
	rows, err := db.db.QueryContext(ctx, "SELECT * FROM User")
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var l []User
	for rows.Next() {
		var user User
		if err := scan(&user, rows); err != nil {
			return nil, err
		}
		l = append(l, user)
	}

	return l, rows.Close()
}

func (db *DB) FetchClient(ctx context.Context, id ID[*Client]) (*Client, error) {
	rows, err := db.db.QueryContext(ctx, "SELECT * FROM Client WHERE id = ?", id)
	if err != nil {
		return nil, err
	}
	var client Client
	err = scanRow(&client, rows)
	return &client, err
}

func (db *DB) FetchClientByClientID(ctx context.Context, clientID string) (*Client, error) {
	rows, err := db.db.QueryContext(ctx, "SELECT * FROM Client WHERE client_id = ?", clientID)
	if err != nil {
		return nil, err
	}
	var client Client
	err = scanRow(&client, rows)
	return &client, err
}

func (db *DB) StoreClient(ctx context.Context, client *Client) error {
	return db.db.QueryRowContext(ctx, `
		INSERT INTO Client(id, client_id, client_secret_hash, owner,
			redirect_uris, client_name, client_uri)
		VALUES (:id, :client_id, :client_secret_hash, :owner,
			:redirect_uris, :client_name, :client_uri)
		ON CONFLICT(id) DO UPDATE SET
			client_id = :client_id,
			client_secret_hash = :client_secret_hash,
			owner = :owner,
			redirect_uris = :redirect_uris,
			client_name = :client_name,
			client_uri = :client_uri
		RETURNING id
	`, entityArgs(client)...).Scan(&client.ID)
}

func (db *DB) ListClients(ctx context.Context, owner ID[*User]) ([]Client, error) {
	rows, err := db.db.QueryContext(ctx, "SELECT * FROM Client WHERE owner IS ?", owner)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var l []Client
	for rows.Next() {
		var client Client
		if err := scan(&client, rows); err != nil {
			return nil, err
		}
		l = append(l, client)
	}

	return l, rows.Close()
}

func (db *DB) ListAuthorizedClients(ctx context.Context, user ID[*User]) ([]AuthorizedClient, error) {
	rows, err := db.db.QueryContext(ctx, `
		SELECT client_id, client_name, client_uri, token.expires_at
		SELECT id, client_id, client_name, client_uri, token.expires_at
		FROM Client,
		(
			SELECT client, MAX(expires_at) as expires_at
			FROM AccessToken
			WHERE user = ?
			GROUP BY client
		) AS token
		WHERE Client.id = token.client
	`, user)
	if err != nil {
		return nil, err
	}

	var l []AuthorizedClient
	for rows.Next() {
		var authClient AuthorizedClient
		columns := authClient.Client.columns()
		var expiresAt string
		err := rows.Scan(columns["client_id"], columns["client_name"], columns["client_uri"], &expiresAt)
		err := rows.Scan(columns["id"], columns["client_id"], columns["client_name"], columns["client_uri"], &expiresAt)
		if err != nil {
			return nil, err
		}
		authClient.ExpiresAt, err = time.Parse(sqlite3.SQLiteTimestampFormats[0], expiresAt)
		if err != nil {
			return nil, err
		}
		l = append(l, authClient)
	}

	return l, rows.Close()
}

func (db *DB) DeleteClient(ctx context.Context, id ID[*Client]) error {
	_, err := db.db.ExecContext(ctx, "DELETE FROM Client WHERE id = ?", id)
	return err
}

func (db *DB) FetchAccessToken(ctx context.Context, id ID[*AccessToken]) (*AccessToken, error) {
	rows, err := db.db.QueryContext(ctx, "SELECT * FROM AccessToken WHERE id = ?", id)
	if err != nil {
		return nil, err
	}
	var token AccessToken
	err = scanRow(&token, rows)
	return &token, err
}

func (db *DB) CreateAccessToken(ctx context.Context, token *AccessToken) error {
	return db.db.QueryRowContext(ctx, `
		INSERT INTO AccessToken(hash, user, client, scope, issued_at, expires_at)
		VALUES (:hash, :user, :client, :scope, :issued_at, :expires_at)
		RETURNING id
	`, entityArgs(token)...).Scan(&token.ID)
}

func (db *DB) RevokeAccessTokens(ctx context.Context, clientID ID[*Client], userID ID[*User]) error {
	_, err := db.db.ExecContext(ctx, `
		DELETE FROM AccessToken
		WHERE client = ? AND user = ?
	`, clientID, userID)
	return err
}

func (db *DB) CreateAuthCode(ctx context.Context, code *AuthCode) error {
	return db.db.QueryRowContext(ctx, `
		INSERT INTO AuthCode(hash, created_at, user, client, scope)
		VALUES (:hash, :created_at, :user, :client, :scope)
		RETURNING id
	`, entityArgs(code)...).Scan(&code.ID)
}

func (db *DB) PopAuthCode(ctx context.Context, id ID[*AuthCode]) (*AuthCode, error) {
	rows, err := db.db.QueryContext(ctx, `
		DELETE FROM AuthCode
		WHERE id = ?
		RETURNING *
	`, id)
	if err != nil {
		return nil, err
	}
	var authCode AuthCode
	err = scanRow(&authCode, rows)
	return &authCode, err
}

func (db *DB) Maintain(ctx context.Context) error {
	_, err := db.db.ExecContext(ctx, `
		DELETE FROM AccessToken
		WHERE timediff('now', expires_at) > 0
	`)
	if err != nil {
		return err
	}

	_, err = db.db.ExecContext(ctx, `
		DELETE FROM AuthCode
		WHERE timediff(?, created_at) > 0
	`, time.Now().Add(-authCodeExpiration))
	if err != nil {
		return err
	}

	return nil
}

func scan(e entity, rows *sql.Rows) error {
	columns := e.columns()

	keys, err := rows.Columns()
	if err != nil {
		panic(err)
	}
	out := make([]interface{}, len(keys))
	for i, k := range keys {
		v, ok := columns[k]
		if !ok {
			panic(fmt.Errorf("unknown column %q", k))
		}
		out[i] = v
	}

	return rows.Scan(out...)
}

func scanRow(e entity, rows *sql.Rows) error {
	if !rows.Next() {
		return sql.ErrNoRows
	}
	if err := scan(e, rows); err != nil {
		return err
	}
	return rows.Close()
}

func entityArgs(e entity) []interface{} {
	columns := e.columns()

	l := make([]interface{}, 0, len(columns))
	for k, v := range columns {
		l = append(l, sql.Named(k, v))
	}

	return l
}
package main

import (
	"context"
	"embed"
	"flag"
	"html/template"
	"log"
	"net"
	"net/http"
	"time"

	"github.com/go-chi/chi/v5"
)

var (
	//go:embed template
	templateFS embed.FS
	//go:embed static
	staticFS embed.FS
)

func main() {
	var configFilename, listenAddr string
	flag.StringVar(&configFilename, "config", "/etc/sinwon/config", "Configuration filename")
	flag.StringVar(&listenAddr, "listen", "", "HTTP listen address")
	flag.Parse()

	cfg, err := loadConfig(configFilename)
	if err != nil {
		log.Fatalf("Failed to load config file: %v", err)
	}

	if listenAddr == "" {
		listenAddr = cfg.Listen
	}
	if listenAddr == "" {
		log.Fatalf("Missing listen configuration")
	}
	if cfg.Database == "" {
		log.Fatalf("Missing database configuration")
	}

	db, err := openDB(cfg.Database)
	if err != nil {
		log.Fatalf("Failed to open DB: %v", err)
	}

	tpl := template.Must(template.ParseFS(templateFS, "template/*.html"))

	mux := chi.NewRouter()
	mux.Handle("/static/*", http.FileServer(http.FS(staticFS)))
	mux.Get("/", index)
	mux.HandleFunc("/login", login)
	mux.Post("/logout", logout)
	mux.HandleFunc("/client/new", manageClient)
	mux.HandleFunc("/client/{id}", manageClient)
	mux.Post("/client/{id}/revoke", revokeClient)
	mux.HandleFunc("/user/new", manageUser)
	mux.HandleFunc("/user/{id}", manageUser)
	mux.Get("/.well-known/oauth-authorization-server", getOAuthServerMetadata)
	mux.HandleFunc("/authorize", authorize)
	mux.Post("/token", exchangeToken)
	mux.Post("/introspect", introspectToken)

	go maintainDBLoop(db)

	server := http.Server{
		Addr:    listenAddr,
		Handler: loginTokenMiddleware(mux),
		BaseContext: func(net.Listener) context.Context {
			return newBaseContext(db, tpl)
		},
	}
	log.Printf("OAuth server listening on %v", server.Addr)
	if err := server.ListenAndServe(); err != nil {
		log.Fatalf("Failed to listen and serve: %v", err)
	}
}

func httpError(w http.ResponseWriter, err error) {
	log.Print(err)
	http.Error(w, "Internal server error", http.StatusInternalServerError)
}

func maintainDBLoop(db *DB) {
	ticker := time.NewTicker(15 * time.Minute)
	defer ticker.Stop()

	for range ticker.C {
		ctx := context.Background()
		ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
		if err := db.Maintain(ctx); err != nil {
			log.Printf("Failed to perform database maintenance: %v", err)
		}
		cancel()
	}
}
{{ template "head.html" }}

<main>

<h1>sinwon</h1>

<p>Welcome, {{ .Me.Username }}!</p>

<form method="post">
	<a href="/user/{{ .Me.ID }}"><button type="button">Settings</button></a>
	<button type="submit" formaction="/logout">Logout</button>
</form>

<h2>Authorized clients</h2>

{{ with .AuthorizedClients }}
	<table>
		<tr>
			<th>Client</th>
			<th>Authorized until</th>
			<th></th>
		</tr>
		{{ range . }}
			<tr>
				<td>
					{{ with .Client }}
						{{ if .ClientURI }}
							<a href="{{ .ClientURI }}" target="_blank">
						{{ end }}
						{{ if .ClientName }}
							{{ .ClientName }}
						{{ else }}
							<code>{{ .ClientID }}</code>
						{{ end }}
						{{ if .ClientURI }}
							</a>
						{{ end }}
					{{ end }}
				</td>
				<td>{{ .ExpiresAt }}</td>
				<td>
					<form method="post" action="/client/{{ .Client.ID }}/revoke">
						<button type="submit">Revoke</button>
					</form>
				</td>
			</tr>
		{{ end }}
	</table>
{{ else }}
	<p>No client authorized yet.</p>
{{ end }}

{{ if .Me.Admin }}
	<h2>Registered clients</h2>

	{{ with .Clients }}
		<p>
			<a href="/client/new"><button type="button">Register new client</button></a>
		</p>

		<table>
			<tr>
				<th>Client ID</th>
				<th>Name</th>
			</tr>
			{{ range . }}
				<tr>
					<td><a href="/client/{{ .ID }}"><code>{{ .ClientID }}</code></a></td>
					<td>{{ .ClientName }}</td>
				</tr>
			{{ end }}
		</table>
	{{ else }}
		<p>No client registered yet.</p>
	{{ end }}

	<h2>Users</h2>

	<p>
		<a href="/user/new"><button type="button">Create user</button></a>
	</p>

	<table>
		<tr>
			<th>Username</th>
			<th>Role</th>
		</tr>
		{{ range .Users }}
			<tr>
				<td><a href="/user/{{ .ID }}">{{ .Username }}</a></td>
				<td>
					{{ if .Admin }}
						Administrator
					{{ else }}
						Regular user
					{{ end}}
				</td>
			</tr>
		{{ end }}
	</table>
{{ end }}

</main>

{{ template "foot.html" }}