Hi… I am well aware that this diff view is very suboptimal. It will be fixed when the refactored server comes along!
Add logout route
package main
import (
"context"
"embed"
"flag"
"html/template"
"log"
"net"
"net/http"
"github.com/go-chi/chi/v5"
)
var (
//go:embed template
templateFS embed.FS
//go:embed static
staticFS embed.FS
)
func main() {
var listenAddr string
flag.StringVar(&listenAddr, "listen", ":8080", "HTTP listen address")
flag.Parse()
tpl := template.Must(template.ParseFS(templateFS, "template/*.html"))
db, err := openDB("sinwon.db")
if err != nil {
log.Fatalf("Failed to open DB: %v", err)
}
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.HandleFunc("/user/new", updateUser)
mux.HandleFunc("/user/{id}", updateUser)
mux.HandleFunc("/authorize", authorize)
mux.Post("/token", exchangeToken)
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)
}
package main
import (
"context"
"fmt"
"html/template"
"net/http"
)
const internalTokenScope = "_sinwon"
type contextKey string
const (
contextKeyDB = "db"
contextKeyTemplate = "template"
contextKeyLoginToken = "login-token"
)
func dbFromContext(ctx context.Context) *DB {
return ctx.Value(contextKeyDB).(*DB)
}
func templateFromContext(ctx context.Context) *template.Template {
return ctx.Value(contextKeyTemplate).(*template.Template)
}
func loginTokenFromContext(ctx context.Context) *AccessToken {
v := ctx.Value(contextKeyLoginToken)
if v == nil {
return nil
}
return v.(*AccessToken)
}
func newBaseContext(db *DB, tpl *template.Template) context.Context {
ctx := context.Background()
ctx = context.WithValue(ctx, contextKeyDB, db)
ctx = context.WithValue(ctx, contextKeyTemplate, tpl)
return ctx
}
func setLoginTokenCookie(w http.ResponseWriter, token *AccessToken, secret string) {
http.SetCookie(w, &http.Cookie{
Name: "sinwon-token",
Value: MarshalSecret(token.ID, secret),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
// TODO: Secure
})
}
func unsetLoginTokenCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "sinwon-token",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
func loginTokenMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
cookie, _ := req.Cookie("sinwon-token")
if cookie == nil {
next.ServeHTTP(w, req)
return
}
ctx := req.Context()
db := dbFromContext(ctx)
tokenID, tokenSecret, _ := UnmarshalSecret[*AccessToken](cookie.Value)
token, err := db.FetchAccessToken(ctx, tokenID)
if err == errNoDBRows || (err == nil && !token.VerifySecret(tokenSecret)) {
http.SetCookie(w, &http.Cookie{
Name: "sinwon-token",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
unsetLoginTokenCookie(w)
next.ServeHTTP(w, req)
return
} else if err != nil {
httpError(w, fmt.Errorf("failed to fetch access token: %v", err))
return
}
if token.Scope != internalTokenScope {
http.Error(w, "Invalid login token scope", http.StatusForbidden)
return
}
if token.User == 0 {
panic("login token with zero user ID")
}
ctx = context.WithValue(ctx, contextKeyLoginToken, token)
req = req.WithContext(ctx)
next.ServeHTTP(w, req)
})
}
{{ template "head.html" }}
<main>
<h1>sinwon</h1>
<form method="post" action="/client/new">
<form method="post">
<a href="/user/new"><button type="button">Create user</button></a>
<a href="/user/{{ .Me }}"><button type="button">Edit user</button></a>
<button type="submit">Register new client</button>
<button type="submit" formaction="/client/new">Register new client</button> <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>
{{ end }}
</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
}
clients, err := db.ListClients(ctx, loginToken.User)
if err != nil {
httpError(w, err)
return
}
data := struct {
Clients []Client
Me ID[*User]
}{
Clients: clients,
Me: loginToken.User,
}
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, &token, secret)
http.Redirect(w, req, redirectURI.String(), http.StatusFound)
}
func logout(w http.ResponseWriter, req *http.Request) {
unsetLoginTokenCookie(w)
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 {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
username := req.PostFormValue("username")
password := req.PostFormValue("password")
if username == "" {
if err := tpl.ExecuteTemplate(w, "update-user.html", user); err != nil {
panic(err)
}
return
}
user.Username = username
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)
}