mirror of https://github.com/jlelse/GoBlog
Simple blogging system written in Go
https://goblog.app
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
281 lines
7.9 KiB
281 lines
7.9 KiB
package main |
|
|
|
import ( |
|
"crypto/sha1" |
|
"database/sql" |
|
"encoding/json" |
|
"errors" |
|
"fmt" |
|
"net/http" |
|
"net/url" |
|
"strings" |
|
"time" |
|
|
|
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype" |
|
"github.com/spf13/cast" |
|
) |
|
|
|
// https://www.w3.org/TR/indieauth/ |
|
// https://indieauth.spec.indieweb.org/ |
|
|
|
type indieAuthData struct { |
|
ClientID string |
|
RedirectURI string |
|
State string |
|
Scopes []string |
|
code string |
|
token string |
|
time time.Time |
|
} |
|
|
|
func (a *goBlog) indieAuthRequest(w http.ResponseWriter, r *http.Request) { |
|
// Authorization request |
|
if err := r.ParseForm(); err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
data := &indieAuthData{ |
|
ClientID: r.Form.Get("client_id"), |
|
RedirectURI: r.Form.Get("redirect_uri"), |
|
State: r.Form.Get("state"), |
|
} |
|
if rt := r.Form.Get("response_type"); rt != "code" && rt != "id" && rt != "" { |
|
a.serveError(w, r, "response_type must be code", http.StatusBadRequest) |
|
return |
|
} |
|
if scope := r.Form.Get("scope"); scope != "" { |
|
data.Scopes = strings.Split(scope, " ") |
|
} |
|
if !isValidProfileURL(data.ClientID) || !isValidProfileURL(data.RedirectURI) { |
|
a.serveError(w, r, "client_id and redirect_uri need to by valid URLs", http.StatusBadRequest) |
|
return |
|
} |
|
if data.State == "" { |
|
a.serveError(w, r, "state must not be empty", http.StatusBadRequest) |
|
return |
|
} |
|
a.render(w, r, "indieauth", &renderData{ |
|
Data: data, |
|
}) |
|
} |
|
|
|
func isValidProfileURL(profileURL string) bool { |
|
u, err := url.Parse(profileURL) |
|
if err != nil { |
|
return false |
|
} |
|
if u.Scheme != "http" && u.Scheme != "https" { |
|
return false |
|
} |
|
if u.Fragment != "" { |
|
return false |
|
} |
|
if u.User.String() != "" { |
|
return false |
|
} |
|
if u.Port() != "" { |
|
return false |
|
} |
|
// Missing: Check domain / ip |
|
return true |
|
} |
|
|
|
func (a *goBlog) indieAuthAccept(w http.ResponseWriter, r *http.Request) { |
|
// Authentication flow |
|
if err := r.ParseForm(); err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
data := &indieAuthData{ |
|
ClientID: r.Form.Get("client_id"), |
|
RedirectURI: r.Form.Get("redirect_uri"), |
|
State: r.Form.Get("state"), |
|
Scopes: r.Form["scopes"], |
|
time: time.Now(), |
|
} |
|
sha := sha1.New() |
|
if _, err := sha.Write([]byte(data.time.String() + data.ClientID)); err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusInternalServerError) |
|
return |
|
} |
|
data.code = fmt.Sprintf("%x", sha.Sum(nil)) |
|
err := a.db.saveAuthorization(data) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusInternalServerError) |
|
return |
|
} |
|
http.Redirect(w, r, data.RedirectURI+"?code="+data.code+"&state="+data.State, http.StatusFound) |
|
} |
|
|
|
type tokenResponse struct { |
|
AccessToken string `json:"access_token,omitempty"` |
|
TokenType string `json:"token_type,omitempty"` |
|
Scope string `json:"scope,omitempty"` |
|
Me string `json:"me,omitempty"` |
|
ClientID string `json:"client_id,omitempty"` |
|
} |
|
|
|
func (a *goBlog) indieAuthVerification(w http.ResponseWriter, r *http.Request) { |
|
// Authorization verification |
|
if err := r.ParseForm(); err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
data := &indieAuthData{ |
|
code: r.Form.Get("code"), |
|
ClientID: r.Form.Get("client_id"), |
|
RedirectURI: r.Form.Get("redirect_uri"), |
|
} |
|
valid, err := a.db.verifyAuthorization(data) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusInternalServerError) |
|
return |
|
} |
|
if !valid { |
|
a.serveError(w, r, "Authentication not valid", http.StatusForbidden) |
|
return |
|
} |
|
b, _ := json.Marshal(tokenResponse{ |
|
Me: a.cfg.Server.PublicAddress, |
|
}) |
|
w.Header().Set(contentType, contenttype.JSONUTF8) |
|
_, _ = a.min.Write(w, contenttype.JSON, b) |
|
} |
|
|
|
func (a *goBlog) indieAuthToken(w http.ResponseWriter, r *http.Request) { |
|
if r.Method == http.MethodGet { |
|
// Token verification |
|
data, err := a.db.verifyIndieAuthToken(r.Header.Get("Authorization")) |
|
if err != nil { |
|
a.serveError(w, r, "Invalid token or token not found", http.StatusUnauthorized) |
|
return |
|
} |
|
res := &tokenResponse{ |
|
Scope: strings.Join(data.Scopes, " "), |
|
Me: a.cfg.Server.PublicAddress, |
|
ClientID: data.ClientID, |
|
} |
|
b, _ := json.Marshal(res) |
|
w.Header().Set(contentType, contenttype.JSONUTF8) |
|
_, _ = a.min.Write(w, contenttype.JSON, b) |
|
return |
|
} else if r.Method == http.MethodPost { |
|
if err := r.ParseForm(); err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusBadRequest) |
|
return |
|
} |
|
// Token Revocation |
|
if r.Form.Get("action") == "revoke" { |
|
a.db.revokeIndieAuthToken(r.Form.Get("token")) |
|
w.WriteHeader(http.StatusOK) |
|
return |
|
} |
|
// Token request |
|
if r.Form.Get("grant_type") == "authorization_code" { |
|
data := &indieAuthData{ |
|
code: r.Form.Get("code"), |
|
ClientID: r.Form.Get("client_id"), |
|
RedirectURI: r.Form.Get("redirect_uri"), |
|
} |
|
valid, err := a.db.verifyAuthorization(data) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusInternalServerError) |
|
return |
|
} |
|
if !valid { |
|
a.serveError(w, r, "Authentication not valid", http.StatusForbidden) |
|
return |
|
} |
|
if len(data.Scopes) < 1 { |
|
a.serveError(w, r, "No scope", http.StatusBadRequest) |
|
return |
|
} |
|
data.time = time.Now() |
|
sha := sha1.New() |
|
if _, err := sha.Write([]byte(data.time.String() + data.ClientID)); err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusInternalServerError) |
|
return |
|
} |
|
data.token = fmt.Sprintf("%x", sha.Sum(nil)) |
|
err = a.db.saveToken(data) |
|
if err != nil { |
|
a.serveError(w, r, err.Error(), http.StatusInternalServerError) |
|
return |
|
} |
|
res := &tokenResponse{ |
|
TokenType: "Bearer", |
|
AccessToken: data.token, |
|
Scope: strings.Join(data.Scopes, " "), |
|
Me: a.cfg.Server.PublicAddress, |
|
} |
|
b, _ := json.Marshal(res) |
|
w.Header().Set(contentType, contenttype.JSONUTF8) |
|
_, _ = a.min.Write(w, contenttype.JSON, b) |
|
return |
|
} |
|
a.serveError(w, r, "", http.StatusBadRequest) |
|
return |
|
} |
|
} |
|
|
|
func (db *database) saveAuthorization(data *indieAuthData) (err error) { |
|
_, err = db.exec("insert into indieauthauth (time, code, client, redirect, scope) values (?, ?, ?, ?, ?)", data.time.Unix(), data.code, data.ClientID, data.RedirectURI, strings.Join(data.Scopes, " ")) |
|
return |
|
} |
|
|
|
func (db *database) verifyAuthorization(data *indieAuthData) (valid bool, err error) { |
|
// code valid for 600 seconds |
|
row, err := db.queryRow("select code, client, redirect, scope from indieauthauth where time >= ? and code = ? and client = ? and redirect = ?", time.Now().Unix()-600, data.code, data.ClientID, data.RedirectURI) |
|
if err != nil { |
|
return false, err |
|
} |
|
scope := "" |
|
err = row.Scan(&data.code, &data.ClientID, &data.RedirectURI, &scope) |
|
if err == sql.ErrNoRows { |
|
return false, nil |
|
} else if err != nil { |
|
return false, err |
|
} |
|
if scope != "" { |
|
data.Scopes = strings.Split(scope, " ") |
|
} |
|
valid = true |
|
_, err = db.exec("delete from indieauthauth where code = ? or time < ?", data.code, time.Now().Unix()-600) |
|
data.code = "" |
|
return |
|
} |
|
|
|
func (db *database) saveToken(data *indieAuthData) (err error) { |
|
_, err = db.exec("insert into indieauthtoken (time, token, client, scope) values (?, ?, ?, ?)", data.time.Unix(), data.token, data.ClientID, strings.Join(data.Scopes, " ")) |
|
return |
|
} |
|
|
|
func (db *database) verifyIndieAuthToken(token string) (data *indieAuthData, err error) { |
|
token = strings.ReplaceAll(token, "Bearer ", "") |
|
data = &indieAuthData{ |
|
Scopes: []string{}, |
|
} |
|
row, err := db.queryRow("select time, token, client, scope from indieauthtoken where token = @token", sql.Named("token", token)) |
|
if err != nil { |
|
return nil, err |
|
} |
|
timeString := "" |
|
scope := "" |
|
err = row.Scan(&timeString, &data.token, &data.ClientID, &scope) |
|
if err == sql.ErrNoRows { |
|
return nil, errors.New("token not found") |
|
} else if err != nil { |
|
return nil, err |
|
} |
|
if scope != "" { |
|
data.Scopes = strings.Split(scope, " ") |
|
} |
|
data.time = time.Unix(cast.ToInt64(timeString), 0) |
|
return |
|
} |
|
|
|
func (db *database) revokeIndieAuthToken(token string) { |
|
if token != "" { |
|
_, _ = db.exec("delete from indieauthtoken where token=?", token) |
|
} |
|
}
|
|
|