Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!
Add admin users
package main
import (
"net/http"
)
func createClient(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, clientSecret, err := NewClient(loginToken.User)
if err != nil {
httpError(w, err)
return
}
if err := db.StoreClient(ctx, client); err != nil {
httpError(w, err)
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)
}
}
package main
import (
"crypto/rand"
"crypto/sha512"
"crypto/subtle"
"database/sql"
"database/sql/driver"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const authCodeExpiration = 10 * time.Minute
type entity interface {
columns() map[string]interface{}
}
var (
_ entity = (*User)(nil)
_ entity = (*Client)(nil)
_ entity = (*AccessToken)(nil)
_ entity = (*AuthCode)(nil)
)
type ID[T entity] int64
var (
_ sql.Scanner = (*ID[*User])(nil)
_ driver.Valuer = ID[*User](0)
)
func ParseID[T entity](s string) (ID[T], error) {
u, _ := strconv.ParseUint(s, 10, 63)
if u == 0 {
return 0, fmt.Errorf("invalid ID")
}
return ID[T](u), nil
}
func (ptr *ID[T]) Scan(v interface{}) error {
if v == nil {
*ptr = 0
return nil
}
id, ok := v.(int64)
if !ok {
return fmt.Errorf("cannot scan ID from %T", v)
}
*ptr = ID[T](id)
return nil
}
func (id ID[T]) Value() (driver.Value, error) {
if id == 0 {
return nil, nil
} else {
return int64(id), nil
}
}
type User struct {
ID ID[*User]
Username string
PasswordHash string
Admin bool
}
func (user *User) columns() map[string]interface{} {
return map[string]interface{}{
"id": &user.ID,
"username": &user.Username,
"password_hash": &user.PasswordHash,
"admin": &user.Admin,
}
}
func (user *User) VerifyPassword(password string) error {
// TODO: upgrade hash
return bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
}
func (user *User) SetPassword(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
user.PasswordHash = string(hash)
return nil
}
type Client struct {
ID ID[*Client]
ClientID string
ClientSecretHash []byte
Owner ID[*User]
}
func NewClient(owner ID[*User]) (client *Client, secret string, err error) {
id, err := generateUID()
if err != nil {
return nil, "", fmt.Errorf("failed to generate client ID: %v", err)
}
secret, hash, err := generateSecret()
if err != nil {
return nil, "", fmt.Errorf("failed to generate client secret: %v", err)
}
client = &Client{
ClientID: id,
ClientSecretHash: hash,
Owner: owner,
}
return client, secret, nil
}
func (client *Client) columns() map[string]interface{} {
return map[string]interface{}{
"id": &client.ID,
"client_id": &client.ClientID,
"client_secret_hash": &client.ClientSecretHash,
"owner": &client.Owner,
}
}
func (client *Client) VerifySecret(secret string) bool {
return verifyHash(client.ClientSecretHash, secret)
}
type AccessToken struct {
ID ID[*AccessToken]
Hash []byte
User ID[*User]
Client ID[*Client]
Scope string
IssuedAt time.Time
ExpiresAt time.Time
}
func (token *AccessToken) Generate() (secret string, err error) {
secret, hash, err := generateSecret()
if err != nil {
return "", fmt.Errorf("failed to generate access token secret: %v", err)
}
token.Hash = hash
token.IssuedAt = time.Now()
token.ExpiresAt = time.Now().Add(2 * time.Hour)
return secret, nil
}
func NewAccessTokenFromAuthCode(authCode *AuthCode) (token *AccessToken, secret string, err error) {
token = &AccessToken{
User: authCode.User,
Client: authCode.Client,
Scope: authCode.Scope,
}
secret, err = token.Generate()
return token, secret, err
}
func (token *AccessToken) columns() map[string]interface{} {
return map[string]interface{}{
"id": &token.ID,
"hash": &token.Hash,
"user": &token.User,
"client": &token.Client,
"scope": &token.Scope,
"issued_at": &token.IssuedAt,
"expires_at": &token.ExpiresAt,
}
}
func (token *AccessToken) VerifySecret(secret string) bool {
return verifyHash(token.Hash, secret) && verifyExpiration(token.ExpiresAt)
}
type AuthCode struct {
ID ID[*AuthCode]
Hash []byte
CreatedAt time.Time
User ID[*User]
Client ID[*Client]
Scope string
}
func NewAuthCode(user ID[*User], client ID[*Client], scope string) (code *AuthCode, secret string, err error) {
secret, hash, err := generateSecret()
if err != nil {
return nil, "", fmt.Errorf("failed to generate authentication code secret: %v", err)
}
code = &AuthCode{
Hash: hash,
CreatedAt: time.Now(),
User: user,
Client: client,
Scope: scope,
}
return code, secret, nil
}
func (code *AuthCode) columns() map[string]interface{} {
return map[string]interface{}{
"id": &code.ID,
"hash": &code.Hash,
"created_at": &code.CreatedAt,
"user": &code.User,
"client": &code.Client,
"scope": &code.Scope,
}
}
func (code *AuthCode) VerifySecret(secret string) bool {
return verifyHash(code.Hash, secret) && verifyExpiration(code.CreatedAt.Add(authCodeExpiration))
}
func UnmarshalSecret[T entity](s string) (id ID[T], secret string, err error) {
idStr, secret, _ := strings.Cut(s, ".")
id, err = ParseID[T](idStr)
return id, secret, err
}
func MarshalSecret[T entity](id ID[T], secret string) string {
if id == 0 {
panic("cannot marshal zero ID")
}
return fmt.Sprintf("%v.%v", int64(id), secret)
}
func generateUID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func generateSecret() (secret string, hash []byte, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", nil, err
}
secret = base64.RawURLEncoding.EncodeToString(b)
h := sha512.Sum512(b)
return secret, h[:], nil
}
func verifyHash(hash []byte, secret string) bool {
b, _ := base64.RawURLEncoding.DecodeString(secret)
h := sha512.Sum512(b)
return subtle.ConstantTimeCompare(hash, h[:]) == 1
}
func verifyExpiration(t time.Time) bool {
return time.Now().Before(t)
}
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", ":8080", "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.Post("/client/new", createClient)
mux.HandleFunc("/login", login)
mux.Post("/logout", logout)
mux.Post("/client/new", createClient)
mux.HandleFunc("/user/new", updateUser)
mux.HandleFunc("/user/{id}", updateUser)
mux.Get("/.well-known/oauth-authorization-server", getOAuthServerMetadata)
mux.HandleFunc("/authorize", authorize)
mux.Post("/token", exchangeToken)
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()
}
}
PRAGMA user_version = 1; CREATE TABLE User ( id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE,
password_hash TEXT
password_hash TEXT, admin INTEGER NOT NULL DEFAULT 0
); CREATE TABLE Client ( id INTEGER PRIMARY KEY, client_id TEXT NOT NULL UNIQUE, client_secret_hash BLOB, owner INTEGER, FOREIGN KEY(owner) REFERENCES User(id) ); CREATE TABLE AccessToken ( id INTEGER PRIMARY KEY, hash BLOB NOT NULL UNIQUE, user INTEGER NOT NULL, client INTEGER, scope TEXT, issued_at datetime NOT NULL, expires_at datetime NOT NULL, FOREIGN KEY(user) REFERENCES User(id), FOREIGN KEY(client) REFERENCES Client(id) ); CREATE TABLE AuthCode ( id INTEGER PRIMARY KEY, hash BLOB NOT NULL UNIQUE, created_at datetime NOT NULL, user INTEGER NOT NULL, client INTEGER NOT NULL, scope TEXT, FOREIGN KEY(user) REFERENCES User(id), FOREIGN KEY(client) REFERENCES Client(id) );
{{ template "head.html" }}
<main>
<h1>sinwon</h1>
<p>Welcome, {{ .Me.Username }}!</p>
<form method="post">
<a href="/user/new"><button type="button">Create user</button></a>
<a href="/user/{{ .Me.ID }}"><button type="button">Edit user</button></a>
<button type="submit" formaction="/client/new">Register new client</button>
{{ if .Me.Admin }}
<a href="/user/new"><button type="button">Create user</button></a>
<button type="submit" formaction="/client/new">Register new client</button>
{{ end }}
<a href="/user/{{ .Me.ID }}"><button type="button">Settings</button></a>
<button type="submit" formaction="/logout">Logout</button> </form>
{{ with .Clients }}
<p>{{ . | len }} clients registered:</p>
<ul>
{{ range . }}
<li><code>{{ .ClientID }}</code></li>
{{ end }}
</ul>
{{ else }}
<p>No client registered yet.</p>
{{ if .Me.Admin }}
{{ with .Clients }}
<p>{{ . | len }} clients registered:</p>
<ul>
{{ range . }}
<li><code>{{ .ClientID }}</code></li>
{{ end }}
</ul>
{{ else }}
<p>No client registered yet.</p>
{{ end }}
{{ end }}
</main>
{{ template "foot.html" }}
{{ template "head.html" }}
<main>
<h1>sinwon</h1>
<form method="post" action="">
Username: <input type="text" name="username" value="{{ .Username }}" required><br>
Username: <input type="text" name="username" value="{{ .User.Username }}" required><br>
Password: <input type="password" name="password"><br>
{{ if not (eq .Me.ID .User.ID) }}
<label>
<input type="checkbox" name="admin" {{ if .User.Admin }}checked{{ end }}>
Administrator
</label><br>
{{ end }}
<a href="/"><button type="button">Cancel</button></a>
<button type="submit">
{{ if .Username }}
{{ if .User.ID }}
Update user
{{ else }}
Create user
{{ end }}
</button>
</form>
</main>
{{ template "foot.html" }}
package main
import (
"fmt"
"log"
"net/http"
"net/url"
"github.com/go-chi/chi/v5"
)
func index(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
}
clients, err := db.ListClients(ctx, loginToken.User)
if err != nil {
httpError(w, err)
return
}
data := struct {
Clients []Client
Me *User
}{
Clients: clients,
Me: me,
}
if err := tpl.ExecuteTemplate(w, "index.html", &data); err != nil {
panic(err)
}
}
func login(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
db := dbFromContext(ctx)
tpl := templateFromContext(ctx)
q := req.URL.Query()
rawRedirectURI := q.Get("redirect_uri")
if rawRedirectURI == "" {
rawRedirectURI = "/"
}
redirectURI, err := url.Parse(rawRedirectURI)
if err != nil || redirectURI.Scheme != "" || redirectURI.Opaque != "" || redirectURI.User != nil || redirectURI.Host != "" {
http.Error(w, "Invalid redirect URI", http.StatusBadRequest)
return
}
if loginTokenFromContext(ctx) != nil {
http.Redirect(w, req, redirectURI.String(), http.StatusFound)
return
}
username := req.PostFormValue("username")
password := req.PostFormValue("password")
if username == "" {
if err := tpl.ExecuteTemplate(w, "login.html", nil); err != nil {
panic(err)
}
return
}
user, err := db.FetchUserByUsername(ctx, username)
if err != nil && err != errNoDBRows {
httpError(w, fmt.Errorf("failed to fetch user: %v", err))
return
}
if err == nil {
err = user.VerifyPassword(password)
}
if err != nil {
log.Printf("login failed for user %q: %v", username, err)
// TODO: show error message
if err := tpl.ExecuteTemplate(w, "login.html", nil); err != nil {
panic(err)
}
return
}
token := AccessToken{
User: user.ID,
Scope: internalTokenScope,
}
secret, err := token.Generate()
if err != nil {
httpError(w, fmt.Errorf("failed to generate access token: %v", err))
return
}
if err := db.CreateAccessToken(ctx, &token); err != nil {
httpError(w, fmt.Errorf("failed to create access token: %v", err))
return
}
setLoginTokenCookie(w, req, &token, secret)
http.Redirect(w, req, redirectURI.String(), http.StatusFound)
}
func logout(w http.ResponseWriter, req *http.Request) {
unsetLoginTokenCookie(w, req)
http.Redirect(w, req, "/login", http.StatusFound)
}
func updateUser(w http.ResponseWriter, req *http.Request) {
ctx := req.Context()
db := dbFromContext(ctx)
tpl := templateFromContext(ctx)
user := new(User)
if idStr := chi.URLParam(req, "id"); idStr != "" {
id, err := ParseID[*User](idStr)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user, err = db.FetchUser(ctx, id)
if err != nil {
httpError(w, err)
return
}
}
loginToken := loginTokenFromContext(ctx)
if loginToken == nil {
http.Redirect(w, req, "/login", http.StatusFound)
return
}
if user.ID != 0 && loginToken.User != user.ID {
me, err := db.FetchUser(ctx, loginToken.User)
if err != nil {
httpError(w, err)
return
} else if loginToken.User != user.ID && !me.Admin {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
username := req.PostFormValue("username")
password := req.PostFormValue("password")
admin := req.PostFormValue("admin") == "on"
if username == "" {
if err := tpl.ExecuteTemplate(w, "update-user.html", user); err != nil {
data := struct {
User *User
Me *User
}{
User: user,
Me: me,
}
if err := tpl.ExecuteTemplate(w, "update-user.html", &data); err != nil {
panic(err) } return } user.Username = username
if me.Admin && user.ID != me.ID {
user.Admin = admin
}
if password != "" {
if err := user.SetPassword(password); err != nil {
httpError(w, err)
return
}
}
if err := db.StoreUser(ctx, user); err != nil {
httpError(w, err)
return
}
http.Redirect(w, req, "/", http.StatusFound)
}