Merge pull request 'IndieAuth' (#25) from auth into master

Reviewed-on: #25
This commit is contained in:
h4kor 2022-11-07 19:38:20 +00:00
commit 5c3b6351d8
26 changed files with 1248 additions and 97 deletions

48
auth_test.go Normal file
View File

@ -0,0 +1,48 @@
package owl_test
import (
"h4kor/owl-blogs"
"h4kor/owl-blogs/test/assertions"
"net/http"
"testing"
)
func TestGetRedirctUrisLink(t *testing.T) {
html := []byte("<link rel=\"redirect_uri\" href=\"http://example.com/redirect\" />")
parser := &owl.OwlHtmlParser{}
uris, err := parser.GetRedirctUris(constructResponse(html))
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect")
}
func TestGetRedirctUrisLinkMultiple(t *testing.T) {
html := []byte(`
<link rel="redirect_uri" href="http://example.com/redirect1" />
<link rel="redirect_uri" href="http://example.com/redirect2" />
<link rel="redirect_uri" href="http://example.com/redirect3" />
<link rel="foo" href="http://example.com/redirect4" />
<link href="http://example.com/redirect5" />
`)
parser := &owl.OwlHtmlParser{}
uris, err := parser.GetRedirctUris(constructResponse(html))
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect1")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect2")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect3")
assertions.AssertLen(t, uris, 3)
}
func TestGetRedirectUrisLinkHeader(t *testing.T) {
html := []byte("")
parser := &owl.OwlHtmlParser{}
resp := constructResponse(html)
resp.Header = http.Header{"Link": []string{"<http://example.com/redirect>; rel=\"redirect_uri\""}}
uris, err := parser.GetRedirctUris(resp)
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect")
}

View File

@ -3,7 +3,6 @@ package main
import ( import (
"fmt" "fmt"
"h4kor/owl-blogs" "h4kor/owl-blogs"
"math/rand"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -35,14 +34,7 @@ var resetPasswordCmd = &cobra.Command{
} }
// generate a random password and print it // generate a random password and print it
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" password := owl.GenerateRandomString(16)
b := make([]byte, 16)
for i := range b {
b[i] = chars[rand.Intn(len(chars))]
}
password := string(b)
user.ResetPassword(password) user.ResetPassword(password)
fmt.Println("User: ", user.Name()) fmt.Println("User: ", user.Name())

View File

@ -3,7 +3,7 @@ package web_test
import ( import (
"h4kor/owl-blogs" "h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web" main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/priv/assertions" "h4kor/owl-blogs/test/assertions"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"

428
cmd/owl/web/auth_test.go Normal file
View File

@ -0,0 +1,428 @@
package web_test
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"h4kor/owl-blogs/test/mocks"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
func TestAuthPostWrongPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.AuthUrl()+"verify/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertContains(t, rr.Header().Get("Location"), "error=invalid_password")
}
func TestAuthPostCorrectPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "testpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.AuthUrl()+"verify/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertContains(t, rr.Header().Get("Location"), "code=")
assertions.AssertContains(t, rr.Header().Get("Location"), "state=test_state")
assertions.AssertContains(t, rr.Header().Get("Location"), "iss="+user.FullUrl())
assertions.AssertContains(t, rr.Header().Get("Location"), "http://example.com/response")
}
func TestAuthPostWithIncorrectCode(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile")
// Create Request and Response
form := url.Values{}
form.Add("code", "wrongcode")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusUnauthorized)
}
func TestAuthPostWithCorrectCode(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile")
// Create Request and Response
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// parse response as json
type responseType struct {
Me string `json:"me"`
}
var response responseType
json.Unmarshal(rr.Body.Bytes(), &response)
assertions.AssertEqual(t, response.Me, user.FullUrl())
}
func TestAuthPostWithCorrectCodeAndPKCE(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
// Create Request and Response
code_verifier := "test_code_verifier"
// create code challenge
h := sha256.New()
h.Write([]byte(code_verifier))
code_challenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "S256", "profile")
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
form.Add("code_verifier", code_verifier)
req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// parse response as json
type responseType struct {
Me string `json:"me"`
}
var response responseType
json.Unmarshal(rr.Body.Bytes(), &response)
assertions.AssertEqual(t, response.Me, user.FullUrl())
}
func TestAuthPostWithCorrectCodeAndWrongPKCE(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
// Create Request and Response
code_verifier := "test_code_verifier"
// create code challenge
h := sha256.New()
h.Write([]byte(code_verifier + "wrong"))
code_challenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "S256", "profile")
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
form.Add("code_verifier", code_verifier)
req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusUnauthorized)
}
func TestAuthPostWithCorrectCodePKCEPlain(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
// Create Request and Response
code_verifier := "test_code_verifier"
code_challenge := code_verifier
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "plain", "profile")
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
form.Add("code_verifier", code_verifier)
req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestAuthPostWithCorrectCodePKCEPlainWrong(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
// Create Request and Response
code_verifier := "test_code_verifier"
code_challenge := code_verifier + "wrong"
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "plain", "profile")
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
form.Add("code_verifier", code_verifier)
req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusUnauthorized)
}
func TestAuthRedirectUriNotSet(t *testing.T) {
repo, user := getSingleUserTestRepo()
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockParseLinksHtmlParser{
Links: []string{"http://example2.com/response"},
}
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example2.com/response_not_set")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("GET", user.AuthUrl()+"?"+form.Encode(), nil)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusBadRequest)
}
func TestAuthRedirectUriSet(t *testing.T) {
repo, user := getSingleUserTestRepo()
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockParseLinksHtmlParser{
Links: []string{"http://example.com/response"},
}
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("GET", user.AuthUrl()+"?"+form.Encode(), nil)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestAuthRedirectUriSameHost(t *testing.T) {
repo, user := getSingleUserTestRepo()
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockParseLinksHtmlParser{
Links: []string{},
}
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("GET", user.AuthUrl()+"?"+form.Encode(), nil)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestAccessTokenCorrectPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile create")
// Create Request and Response
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", user.AuthUrl()+"token/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// parse response as json
type responseType struct {
Me string `json:"me"`
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}
var response responseType
json.Unmarshal(rr.Body.Bytes(), &response)
assertions.AssertEqual(t, response.Me, user.FullUrl())
assertions.AssertEqual(t, response.TokenType, "Bearer")
assertions.AssertEqual(t, response.Scope, "profile create")
assertions.Assert(t, response.ExpiresIn > 0, "ExpiresIn should be greater than 0")
assertions.Assert(t, len(response.AccessToken) > 0, "AccessToken should be greater than 0")
}
func TestAccessTokenWithIncorrectCode(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile")
// Create Request and Response
form := url.Values{}
form.Add("code", "wrongcode")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", user.AuthUrl()+"token/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusUnauthorized)
}
func TestIndieauthMetadata(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
req, _ := http.NewRequest("GET", user.IndieauthMetadataUrl(), nil)
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// parse response as json
type responseType struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
ScopesSupported []string `json:"scopes_supported"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
}
var response responseType
json.Unmarshal(rr.Body.Bytes(), &response)
assertions.AssertEqual(t, response.Issuer, user.FullUrl())
assertions.AssertEqual(t, response.AuthorizationEndpoint, user.AuthUrl())
assertions.AssertEqual(t, response.TokenEndpoint, user.TokenUrl())
}

View File

@ -1,6 +1,7 @@
package web package web
import ( import (
"encoding/json"
"fmt" "fmt"
"h4kor/owl-blogs" "h4kor/owl-blogs"
"net/http" "net/http"
@ -59,6 +60,352 @@ func userIndexHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Requ
} }
} }
func userAuthMetadataHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
type Response struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
ScopesSupported []string `json:"scopes_supported"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
}
response := Response{
Issuer: user.FullUrl(),
AuthorizationEndpoint: user.AuthUrl(),
TokenEndpoint: user.TokenUrl(),
CodeChallengeMethodsSupported: []string{"S256", "plain"},
ScopesSupported: []string{"profile"},
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code"},
}
jsonData, err := json.Marshal(response)
if err != nil {
println("Error marshalling json: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
}
w.Write(jsonData)
}
}
func userAuthHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
// get me, cleint_id, redirect_uri, state and response_type from query
me := r.URL.Query().Get("me")
clientId := r.URL.Query().Get("client_id")
redirectUri := r.URL.Query().Get("redirect_uri")
state := r.URL.Query().Get("state")
responseType := r.URL.Query().Get("response_type")
codeChallenge := r.URL.Query().Get("code_challenge")
codeChallengeMethod := r.URL.Query().Get("code_challenge_method")
scope := r.URL.Query().Get("scope")
// check if request is valid
missing_params := []string{}
if clientId == "" {
missing_params = append(missing_params, "client_id")
}
if redirectUri == "" {
missing_params = append(missing_params, "redirect_uri")
}
if responseType == "" {
missing_params = append(missing_params, "response_type")
}
if len(missing_params) > 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(fmt.Sprintf("Missing parameters: %s", strings.Join(missing_params, ", "))))
return
}
if responseType != "id" {
responseType = "code"
}
if responseType != "code" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid response_type. Must be 'code' ('id' converted to 'code' for legacy support)."))
return
}
if codeChallengeMethod != "" && (codeChallengeMethod != "S256" && codeChallengeMethod != "plain") {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid code_challenge_method. Must be 'S256' or 'plain'."))
return
}
client_id_url, err := url.Parse(clientId)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid client_id."))
return
}
redirect_uri_url, err := url.Parse(redirectUri)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid redirect_uri."))
return
}
if client_id_url.Host != redirect_uri_url.Host || client_id_url.Scheme != redirect_uri_url.Scheme {
// check if redirect_uri is registered
resp, _ := repo.HttpClient.Get(clientId)
registered_redirects, _ := repo.Parser.GetRedirctUris(resp)
is_registered := false
for _, registered_redirect := range registered_redirects {
if registered_redirect == redirectUri {
// redirect_uri is registered
is_registered = true
break
}
}
if !is_registered {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid redirect_uri. Must be registered with client_id."))
return
}
}
// Double Submit Cookie Pattern
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
csrfToken := owl.GenerateRandomString(32)
cookie := http.Cookie{
Name: "csrf_token",
Value: csrfToken,
}
http.SetCookie(w, &cookie)
reqData := owl.AuthRequestData{
Me: me,
ClientId: clientId,
RedirectUri: redirectUri,
State: state,
Scope: scope,
ResponseType: responseType,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
User: user,
CsrfToken: csrfToken,
}
html, err := owl.RenderUserAuthPage(reqData)
if err != nil {
println("Error rendering auth page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
return
}
println("Rendering auth page for user", user.Name())
w.Write([]byte(html))
}
}
func verifyAuthCodeRequest(user owl.User, w http.ResponseWriter, r *http.Request) (bool, owl.AuthCode) {
// get form data from post request
err := r.ParseForm()
if err != nil {
println("Error parsing form: ", err.Error())
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Error parsing form"))
return false, owl.AuthCode{}
}
code := r.Form.Get("code")
client_id := r.Form.Get("client_id")
redirect_uri := r.Form.Get("redirect_uri")
code_verifier := r.Form.Get("code_verifier")
// check if request is valid
valid, authCode := user.VerifyAuthCode(code, client_id, redirect_uri, code_verifier)
if !valid {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Invalid code"))
}
return valid, authCode
}
func userAuthProfileHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
valid, _ := verifyAuthCodeRequest(user, w, r)
if valid {
w.WriteHeader(http.StatusOK)
type ResponseProfile struct {
Name string `json:"name"`
Url string `json:"url"`
Photo string `json:"photo"`
}
type Response struct {
Me string `json:"me"`
Profile ResponseProfile `json:"profile"`
}
response := Response{
Me: user.FullUrl(),
Profile: ResponseProfile{
Name: user.Name(),
Url: user.FullUrl(),
Photo: user.AvatarUrl(),
},
}
jsonData, err := json.Marshal(response)
if err != nil {
println("Error marshalling json: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
}
w.Write(jsonData)
return
}
}
}
func userAuthTokenHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
valid, authCode := verifyAuthCodeRequest(user, w, r)
if valid {
if authCode.Scope == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Empty scope, no token issued"))
return
}
type Response struct {
Me string `json:"me"`
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
Scope string `json:"scope"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
}
accessToken, duration, err := user.GenerateAccessToken(authCode)
if err != nil {
println("Error generating access token: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
return
}
response := Response{
Me: user.FullUrl(),
TokenType: "Bearer",
AccessToken: accessToken,
Scope: authCode.Scope,
ExpiresIn: duration,
}
jsonData, err := json.Marshal(response)
if err != nil {
println("Error marshalling json: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
}
w.Write(jsonData)
return
}
}
}
func userAuthVerifyHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
// get form data from post request
err = r.ParseForm()
if err != nil {
println("Error parsing form: ", err.Error())
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Error parsing form"))
return
}
password := r.FormValue("password")
client_id := r.FormValue("client_id")
redirect_uri := r.FormValue("redirect_uri")
response_type := r.FormValue("response_type")
state := r.FormValue("state")
code_challenge := r.FormValue("code_challenge")
code_challenge_method := r.FormValue("code_challenge_method")
scope := r.FormValue("scope")
// CSRF check
formCsrfToken := r.FormValue("csrf_token")
cookieCsrfToken, err := r.Cookie("csrf_token")
if err != nil {
println("Error getting csrf token from cookie: ", err.Error())
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Error getting csrf token from cookie"))
return
}
if formCsrfToken != cookieCsrfToken.Value {
println("Invalid csrf token")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid csrf token"))
return
}
password_valid := user.VerifyPassword(password)
if !password_valid {
redirect := fmt.Sprintf(
"%s?error=invalid_password&client_id=%s&redirect_uri=%s&response_type=%s&state=%s",
user.AuthUrl(), client_id, redirect_uri, response_type, state,
)
if code_challenge != "" {
redirect += fmt.Sprintf("&code_challenge=%s&code_challenge_method=%s", code_challenge, code_challenge_method)
}
http.Redirect(w, r,
redirect,
http.StatusFound,
)
return
} else {
// password is valid, generate code
code, err := user.GenerateAuthCode(
client_id, redirect_uri, code_challenge, code_challenge_method, scope)
if err != nil {
println("Error generating code: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
return
}
http.Redirect(w, r,
fmt.Sprintf(
"%s?code=%s&state=%s&iss=%s",
redirect_uri, code, state,
user.FullUrl(),
),
http.StatusFound,
)
return
}
}
}
func userWebmentionHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) { func userWebmentionHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps) user, err := getUserFromRepo(repo, ps)

View File

@ -3,7 +3,7 @@ package web_test
import ( import (
"h4kor/owl-blogs" "h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web" main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/priv/assertions" "h4kor/owl-blogs/test/assertions"
"math/rand" "math/rand"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"

View File

@ -3,7 +3,7 @@ package web_test
import ( import (
"h4kor/owl-blogs" "h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web" main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/priv/assertions" "h4kor/owl-blogs/test/assertions"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"

View File

@ -3,7 +3,7 @@ package web_test
import ( import (
"h4kor/owl-blogs" "h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web" main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/priv/assertions" "h4kor/owl-blogs/test/assertions"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"

View File

@ -14,11 +14,16 @@ func Router(repo *owl.Repository) http.Handler {
router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir())) router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir()))
router.GET("/", repoIndexHandler(repo)) router.GET("/", repoIndexHandler(repo))
router.GET("/user/:user/", userIndexHandler(repo)) router.GET("/user/:user/", userIndexHandler(repo))
router.GET("/user/:user/auth/", userAuthHandler(repo))
router.POST("/user/:user/auth/", userAuthProfileHandler(repo))
router.POST("/user/:user/auth/verify/", userAuthVerifyHandler(repo))
router.POST("/user/:user/auth/token/", userAuthTokenHandler(repo))
router.GET("/user/:user/media/*filepath", userMediaHandler(repo)) router.GET("/user/:user/media/*filepath", userMediaHandler(repo))
router.GET("/user/:user/index.xml", userRSSHandler(repo)) router.GET("/user/:user/index.xml", userRSSHandler(repo))
router.GET("/user/:user/posts/:post/", postHandler(repo)) router.GET("/user/:user/posts/:post/", postHandler(repo))
router.GET("/user/:user/posts/:post/media/*filepath", postMediaHandler(repo)) router.GET("/user/:user/posts/:post/media/*filepath", postMediaHandler(repo))
router.POST("/user/:user/webmention/", userWebmentionHandler(repo)) router.POST("/user/:user/webmention/", userWebmentionHandler(repo))
router.GET("/user/:user/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo))
router.NotFound = http.HandlerFunc(notFoundHandler(repo)) router.NotFound = http.HandlerFunc(notFoundHandler(repo))
return router return router
} }
@ -27,11 +32,16 @@ func SingleUserRouter(repo *owl.Repository) http.Handler {
router := httprouter.New() router := httprouter.New()
router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir())) router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir()))
router.GET("/", userIndexHandler(repo)) router.GET("/", userIndexHandler(repo))
router.GET("/auth/", userAuthHandler(repo))
router.POST("/auth/", userAuthProfileHandler(repo))
router.POST("/auth/verify/", userAuthVerifyHandler(repo))
router.POST("/auth/token/", userAuthTokenHandler(repo))
router.GET("/media/*filepath", userMediaHandler(repo)) router.GET("/media/*filepath", userMediaHandler(repo))
router.GET("/index.xml", userRSSHandler(repo)) router.GET("/index.xml", userRSSHandler(repo))
router.GET("/posts/:post/", postHandler(repo)) router.GET("/posts/:post/", postHandler(repo))
router.GET("/posts/:post/media/*filepath", postMediaHandler(repo)) router.GET("/posts/:post/media/*filepath", postMediaHandler(repo))
router.POST("/webmention/", userWebmentionHandler(repo)) router.POST("/webmention/", userWebmentionHandler(repo))
router.GET("/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo))
router.NotFound = http.HandlerFunc(notFoundHandler(repo)) router.NotFound = http.HandlerFunc(notFoundHandler(repo))
return router return router
} }

View File

@ -3,7 +3,7 @@ package web_test
import ( import (
owl "h4kor/owl-blogs" owl "h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web" main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/priv/assertions" "h4kor/owl-blogs/test/assertions"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"

View File

@ -3,7 +3,7 @@ package web_test
import ( import (
"h4kor/owl-blogs" "h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web" main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/priv/assertions" "h4kor/owl-blogs/test/assertions"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"

24
embed/auth.html Normal file
View File

@ -0,0 +1,24 @@
<h3>Authorization for {{.ClientId}}</h3>
<h5>Requesting scope:</h5>
<ul>
{{range $index, $element := .Scopes}}
<li>{{$element}}</li>
{{end}}
</ul>
<br><br>
<form action="verify/" method="post">
<label for="password">Password</label>
<input type="password" name="password" placeholder="Password">
<input type="hidden" name="client_id" value="{{.ClientId}}">
<input type="hidden" name="redirect_uri" value="{{.RedirectUri}}">
<input type="hidden" name="response_type" value="{{.ResponseType}}">
<input type="hidden" name="state" value="{{.State}}">
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="code_challenge" value="{{.CodeChallenge}}">
<input type="hidden" name="code_challenge_method" value="{{.CodeChallengeMethod}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="submit" value="Login">
</form>

View File

@ -27,6 +27,11 @@
<link rel="stylesheet" href="/static/pico.min.css"> <link rel="stylesheet" href="/static/pico.min.css">
<link rel="webmention" href="{{ .User.WebmentionUrl }}"> <link rel="webmention" href="{{ .User.WebmentionUrl }}">
{{ if .User.AuthUrl }}
<link rel="indieauth-metadata" href="{{ .User.IndieauthMetadataUrl }}">
<link rel="authorization_endpoint" href="{{ .User.AuthUrl}}">
<link rel="token_endpoint" href="{{ .User.TokenUrl}}">
{{ end }}
<style> <style>
header { header {
background-color: {{.User.Config.HeaderColor}}; background-color: {{.User.Config.HeaderColor}};

View File

@ -2,67 +2,10 @@ package owl_test
import ( import (
"h4kor/owl-blogs" "h4kor/owl-blogs"
"io"
"math/rand" "math/rand"
"net/http"
"net/url"
"time" "time"
) )
type MockHtmlParser struct{}
func (*MockHtmlParser) ParseHEntry(resp *http.Response) (owl.ParsedHEntry, error) {
return owl.ParsedHEntry{Title: "Mock Title"}, nil
}
func (*MockHtmlParser) ParseLinks(resp *http.Response) ([]string, error) {
return []string{"http://example.com"}, nil
}
func (*MockHtmlParser) ParseLinksFromString(string) ([]string, error) {
return []string{"http://example.com"}, nil
}
func (*MockHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error) {
return "http://example.com/webmention", nil
}
type MockParseLinksHtmlParser struct {
Links []string
}
func (*MockParseLinksHtmlParser) ParseHEntry(resp *http.Response) (owl.ParsedHEntry, error) {
return owl.ParsedHEntry{Title: "Mock Title"}, nil
}
func (parser *MockParseLinksHtmlParser) ParseLinks(resp *http.Response) ([]string, error) {
return parser.Links, nil
}
func (parser *MockParseLinksHtmlParser) ParseLinksFromString(string) ([]string, error) {
return parser.Links, nil
}
func (*MockParseLinksHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error) {
return "http://example.com/webmention", nil
}
type MockHttpClient struct{}
func (*MockHttpClient) Get(url string) (resp *http.Response, err error) {
return &http.Response{}, nil
}
func (*MockHttpClient) Post(url, contentType string, body io.Reader) (resp *http.Response, err error) {
return &http.Response{}, nil
}
func (*MockHttpClient) PostForm(url string, data url.Values) (resp *http.Response, err error) {
return &http.Response{}, nil
}
func randomName() string { func randomName() string {
rand.Seed(time.Now().UnixNano()) rand.Seed(time.Now().UnixNano())
var letters = []rune("abcdefghijklmnopqrstuvwxyz") var letters = []rune("abcdefghijklmnopqrstuvwxyz")

View File

@ -2,7 +2,8 @@ package owl_test
import ( import (
"h4kor/owl-blogs" "h4kor/owl-blogs"
"h4kor/owl-blogs/priv/assertions" "h4kor/owl-blogs/test/assertions"
"h4kor/owl-blogs/test/mocks"
"os" "os"
"path" "path"
"strconv" "strconv"
@ -146,8 +147,8 @@ func TestPersistIncomingWebmention(t *testing.T) {
func TestAddIncomingWebmentionCreatesFile(t *testing.T) { func TestAddIncomingWebmentionCreatesFile(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{}) repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &MockHttpClient{} repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &MockHtmlParser{} repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser") user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost("testpost", false) post, _ := user.CreateNewPost("testpost", false)
@ -160,8 +161,8 @@ func TestAddIncomingWebmentionCreatesFile(t *testing.T) {
func TestAddIncomingWebmentionNotOverwritingWebmention(t *testing.T) { func TestAddIncomingWebmentionNotOverwritingWebmention(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{}) repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &MockHttpClient{} repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &MockHtmlParser{} repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser") user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost("testpost", false) post, _ := user.CreateNewPost("testpost", false)
@ -180,8 +181,8 @@ func TestAddIncomingWebmentionNotOverwritingWebmention(t *testing.T) {
func TestEnrichAddsTitle(t *testing.T) { func TestEnrichAddsTitle(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{}) repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &MockHttpClient{} repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &MockHtmlParser{} repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser") user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost("testpost", false) post, _ := user.CreateNewPost("testpost", false)
@ -294,8 +295,8 @@ func TestScanningForLinksDoesAddReplyUrl(t *testing.T) {
func TestCanSendWebmention(t *testing.T) { func TestCanSendWebmention(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{}) repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &MockHttpClient{} repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &MockHtmlParser{} repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser") user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost("testpost", false) post, _ := user.CreateNewPost("testpost", false)
@ -315,8 +316,8 @@ func TestCanSendWebmention(t *testing.T) {
func TestSendWebmentionOnlyScansOncePerWeek(t *testing.T) { func TestSendWebmentionOnlyScansOncePerWeek(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{}) repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &MockHttpClient{} repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &MockHtmlParser{} repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser") user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost("testpost", false) post, _ := user.CreateNewPost("testpost", false)
@ -340,8 +341,8 @@ func TestSendWebmentionOnlyScansOncePerWeek(t *testing.T) {
func TestSendingMultipleWebmentions(t *testing.T) { func TestSendingMultipleWebmentions(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{}) repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &MockHttpClient{} repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &MockHtmlParser{} repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser") user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost("testpost", false) post, _ := user.CreateNewPost("testpost", false)
@ -367,8 +368,8 @@ func TestSendingMultipleWebmentions(t *testing.T) {
func TestReceivingMultipleWebmentions(t *testing.T) { func TestReceivingMultipleWebmentions(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{}) repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &MockHttpClient{} repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &MockHtmlParser{} repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser") user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost("testpost", false) post, _ := user.CreateNewPost("testpost", false)
@ -392,8 +393,8 @@ func TestReceivingMultipleWebmentions(t *testing.T) {
func TestSendingAndReceivingMultipleWebmentions(t *testing.T) { func TestSendingAndReceivingMultipleWebmentions(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{}) repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &MockHttpClient{} repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &MockHtmlParser{} repo.Parser = &mocks.MockHtmlParser{}
user, _ := repo.CreateUser("testuser") user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost("testpost", false) post, _ := user.CreateNewPost("testpost", false)
@ -425,8 +426,8 @@ func TestSendingAndReceivingMultipleWebmentions(t *testing.T) {
func TestComplexParallelWebmentions(t *testing.T) { func TestComplexParallelWebmentions(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{}) repo := getTestRepo(owl.RepoConfig{})
repo.HttpClient = &MockHttpClient{} repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &MockParseLinksHtmlParser{ repo.Parser = &mocks.MockParseLinksHtmlParser{
Links: []string{ Links: []string{
"http://example.com/1", "http://example.com/1",
"http://example.com/2", "http://example.com/2",
@ -469,7 +470,7 @@ func TestComplexParallelWebmentions(t *testing.T) {
// func TestComplexParallelSimulatedProcessesWebmentions(t *testing.T) { // func TestComplexParallelSimulatedProcessesWebmentions(t *testing.T) {
// repoName := testRepoName() // repoName := testRepoName()
// repo, _ := owl.CreateRepository(repoName, owl.RepoConfig{}) // repo, _ := owl.CreateRepository(repoName, owl.RepoConfig{})
// repo.HttpClient = &MockHttpClient{} // repo.HttpClient = &mocks.MockHttpClient{}
// repo.Parser = &MockParseLinksHtmlParser{ // repo.Parser = &MockParseLinksHtmlParser{
// Links: []string{ // Links: []string{
// "http://example.com/1", // "http://example.com/1",

View File

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
_ "embed" _ "embed"
"html/template" "html/template"
"strings"
) )
type PageContent struct { type PageContent struct {
@ -20,6 +21,20 @@ type PostRenderData struct {
Content template.HTML Content template.HTML
} }
type AuthRequestData struct {
Me string
ClientId string
RedirectUri string
State string
Scope string
Scopes []string // Split version of scope. filled by rendering function.
ResponseType string
CodeChallenge string
CodeChallengeMethod string
User User
CsrfToken string
}
func renderEmbedTemplate(templateFile string, data interface{}) (string, error) { func renderEmbedTemplate(templateFile string, data interface{}) (string, error) {
templateStr, err := embed_files.ReadFile(templateFile) templateStr, err := embed_files.ReadFile(templateFile)
if err != nil { if err != nil {
@ -107,7 +122,19 @@ func RenderIndexPage(user User) (string, error) {
Title: "Index", Title: "Index",
Content: template.HTML(postHtml), Content: template.HTML(postHtml),
}) })
}
func RenderUserAuthPage(reqData AuthRequestData) (string, error) {
reqData.Scopes = strings.Split(reqData.Scope, " ")
authHtml, err := renderEmbedTemplate("embed/auth.html", reqData)
if err != nil {
return "", err
}
return renderIntoBaseTemplate(reqData.User, PageContent{
Title: "Auth",
Content: template.HTML(authHtml),
})
} }
func RenderUserList(repo Repository) (string, error) { func RenderUserList(repo Repository) (string, error) {

View File

@ -2,7 +2,7 @@ package owl_test
import ( import (
"h4kor/owl-blogs" "h4kor/owl-blogs"
"h4kor/owl-blogs/priv/assertions" "h4kor/owl-blogs/test/assertions"
"os" "os"
"path" "path"
"testing" "testing"
@ -285,3 +285,41 @@ func TestAddFaviconIfExist(t *testing.T) {
result, _ := owl.RenderIndexPage(user) result, _ := owl.RenderIndexPage(user)
assertions.AssertContains(t, result, "favicon.png") assertions.AssertContains(t, result, "favicon.png")
} }
func TestRenderUserAuth(t *testing.T) {
user := getTestUser()
user.ResetPassword("test")
result, err := owl.RenderUserAuthPage(owl.AuthRequestData{
User: user,
})
assertions.AssertNoError(t, err, "Error rendering user auth page")
assertions.AssertContains(t, result, "<form")
}
func TestRenderUserAuthIncludesClientId(t *testing.T) {
user := getTestUser()
user.ResetPassword("test")
result, err := owl.RenderUserAuthPage(owl.AuthRequestData{
User: user,
ClientId: "https://example.com/",
})
assertions.AssertNoError(t, err, "Error rendering user auth page")
assertions.AssertContains(t, result, "https://example.com/")
}
func TestRenderUserAuthHiddenFields(t *testing.T) {
user := getTestUser()
user.ResetPassword("test")
result, err := owl.RenderUserAuthPage(owl.AuthRequestData{
User: user,
ClientId: "https://example.com/",
RedirectUri: "https://example.com/redirect",
ResponseType: "code",
State: "teststate",
})
assertions.AssertNoError(t, err, "Error rendering user auth page")
assertions.AssertContains(t, result, "name=\"client_id\" value=\"https://example.com/\"")
assertions.AssertContains(t, result, "name=\"redirect_uri\" value=\"https://example.com/redirect\"")
assertions.AssertContains(t, result, "name=\"response_type\" value=\"code\"")
assertions.AssertContains(t, result, "name=\"state\" value=\"teststate\"")
}

View File

@ -2,7 +2,7 @@ package owl_test
import ( import (
"h4kor/owl-blogs" "h4kor/owl-blogs"
"h4kor/owl-blogs/priv/assertions" "h4kor/owl-blogs/test/assertions"
"os" "os"
"path" "path"
"testing" "testing"

View File

@ -2,7 +2,7 @@ package owl_test
import ( import (
"h4kor/owl-blogs" "h4kor/owl-blogs"
"h4kor/owl-blogs/priv/assertions" "h4kor/owl-blogs/test/assertions"
"os" "os"
"testing" "testing"
) )

View File

@ -27,6 +27,16 @@ func AssertContains(t *testing.T, containing string, search string) {
} }
} }
func AssertArrayContains[T comparable](t *testing.T, list []T, search T) {
t.Helper()
for _, item := range list {
if item == search {
return
}
}
t.Errorf("Expected '%v' to be in '%v'", search, list)
}
func AssertNotContains(t *testing.T, containing string, search string) { func AssertNotContains(t *testing.T, containing string, search string) {
t.Helper() t.Helper()
if strings.Contains(containing, search) { if strings.Contains(containing, search) {

64
test/mocks/mocks.go Normal file
View File

@ -0,0 +1,64 @@
package mocks
import (
"h4kor/owl-blogs"
"io"
"net/http"
"net/url"
)
type MockHtmlParser struct{}
func (*MockHtmlParser) ParseHEntry(resp *http.Response) (owl.ParsedHEntry, error) {
return owl.ParsedHEntry{Title: "Mock Title"}, nil
}
func (*MockHtmlParser) ParseLinks(resp *http.Response) ([]string, error) {
return []string{"http://example.com"}, nil
}
func (*MockHtmlParser) ParseLinksFromString(string) ([]string, error) {
return []string{"http://example.com"}, nil
}
func (*MockHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error) {
return "http://example.com/webmention", nil
}
func (*MockHtmlParser) GetRedirctUris(resp *http.Response) ([]string, error) {
return []string{"http://example.com/redirect"}, nil
}
type MockParseLinksHtmlParser struct {
Links []string
}
func (*MockParseLinksHtmlParser) ParseHEntry(resp *http.Response) (owl.ParsedHEntry, error) {
return owl.ParsedHEntry{Title: "Mock Title"}, nil
}
func (parser *MockParseLinksHtmlParser) ParseLinks(resp *http.Response) ([]string, error) {
return parser.Links, nil
}
func (parser *MockParseLinksHtmlParser) ParseLinksFromString(string) ([]string, error) {
return parser.Links, nil
}
func (*MockParseLinksHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error) {
return "http://example.com/webmention", nil
}
func (parser *MockParseLinksHtmlParser) GetRedirctUris(resp *http.Response) ([]string, error) {
return parser.Links, nil
}
type MockHttpClient struct{}
func (*MockHttpClient) Get(url string) (resp *http.Response, err error) {
return &http.Response{}, nil
}
func (*MockHttpClient) Post(url, contentType string, body io.Reader) (resp *http.Response, err error) {
return &http.Response{}, nil
}
func (*MockHttpClient) PostForm(url string, data url.Values) (resp *http.Response, err error) {
return &http.Response{}, nil
}

127
user.go
View File

@ -1,6 +1,8 @@
package owl package owl
import ( import (
"crypto/sha256"
"encoding/base64"
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
@ -31,6 +33,25 @@ type UserMe struct {
Url string `yaml:"url"` Url string `yaml:"url"`
} }
type AuthCode struct {
Code string `yaml:"code"`
ClientId string `yaml:"client_id"`
RedirectUri string `yaml:"redirect_uri"`
CodeChallenge string `yaml:"code_challenge"`
CodeChallengeMethod string `yaml:"code_challenge_method"`
Scope string `yaml:"scope"`
Created time.Time `yaml:"created"`
}
type AccessToken struct {
Token string `yaml:"token"`
Scope string `yaml:"scope"`
ClientId string `yaml:"client_id"`
RedirectUri string `yaml:"redirect_uri"`
Created time.Time `yaml:"created"`
ExpiresIn int `yaml:"expires_in"`
}
func (user User) Dir() string { func (user User) Dir() string {
return path.Join(user.repo.UsersDir(), user.name) return path.Join(user.repo.UsersDir(), user.name)
} }
@ -44,6 +65,24 @@ func (user User) FullUrl() string {
return url return url
} }
func (user User) AuthUrl() string {
if user.Config().PassworHash == "" {
return ""
}
url, _ := url.JoinPath(user.FullUrl(), "auth/")
return url
}
func (user User) TokenUrl() string {
url, _ := url.JoinPath(user.AuthUrl(), "token/")
return url
}
func (user User) IndieauthMetadataUrl() string {
url, _ := url.JoinPath(user.FullUrl(), ".well-known/oauth-authorization-server")
return url
}
func (user User) WebmentionUrl() string { func (user User) WebmentionUrl() string {
url, _ := url.JoinPath(user.FullUrl(), "webmention/") url, _ := url.JoinPath(user.FullUrl(), "webmention/")
return url return url
@ -70,6 +109,14 @@ func (user User) ConfigFile() string {
return path.Join(user.MetaDir(), "config.yml") return path.Join(user.MetaDir(), "config.yml")
} }
func (user User) AuthCodesFile() string {
return path.Join(user.MetaDir(), "auth_codes.yml")
}
func (user User) AccessTokensFile() string {
return path.Join(user.MetaDir(), "access_tokens.yml")
}
func (user User) Name() string { func (user User) Name() string {
return user.name return user.name
} }
@ -252,3 +299,83 @@ func (user User) VerifyPassword(password string) bool {
) )
return err == nil return err == nil
} }
func (user User) getAuthCodes() []AuthCode {
codes := make([]AuthCode, 0)
loadFromYaml(user.AuthCodesFile(), &codes)
return codes
}
func (user User) addAuthCode(code AuthCode) error {
codes := user.getAuthCodes()
codes = append(codes, code)
return saveToYaml(user.AuthCodesFile(), codes)
}
func (user User) GenerateAuthCode(
client_id string, redirect_uri string,
code_challenge string, code_challenge_method string,
scope string,
) (string, error) {
// generate code
code := GenerateRandomString(32)
return code, user.addAuthCode(AuthCode{
Code: code,
ClientId: client_id,
RedirectUri: redirect_uri,
CodeChallenge: code_challenge,
CodeChallengeMethod: code_challenge_method,
Scope: scope,
Created: time.Now(),
})
}
func (user User) VerifyAuthCode(
code string, client_id string, redirect_uri string, code_verifier string,
) (bool, AuthCode) {
codes := user.getAuthCodes()
for _, c := range codes {
if c.Code == code && c.ClientId == client_id && c.RedirectUri == redirect_uri {
if c.CodeChallengeMethod == "plain" {
return c.CodeChallenge == code_verifier, c
} else if c.CodeChallengeMethod == "S256" {
// hash code_verifier
hash := sha256.Sum256([]byte(code_verifier))
return c.CodeChallenge == base64.RawURLEncoding.EncodeToString(hash[:]), c
} else if c.CodeChallengeMethod == "" {
// Check age of code
// A maximum lifetime of 10 minutes is recommended ( https://indieauth.spec.indieweb.org/#authorization-response)
if time.Since(c.Created) < 10*time.Minute {
return true, c
}
}
}
}
return false, AuthCode{}
}
func (user User) getAccessTokens() []AccessToken {
codes := make([]AccessToken, 0)
loadFromYaml(user.AccessTokensFile(), &codes)
return codes
}
func (user User) addAccessToken(code AccessToken) error {
codes := user.getAccessTokens()
codes = append(codes, code)
return saveToYaml(user.AccessTokensFile(), codes)
}
func (user User) GenerateAccessToken(authCode AuthCode) (string, int, error) {
// generate code
token := GenerateRandomString(32)
duration := 24 * 60 * 60
return token, duration, user.addAccessToken(AccessToken{
Token: token,
ClientId: authCode.ClientId,
RedirectUri: authCode.RedirectUri,
Scope: authCode.Scope,
ExpiresIn: duration,
Created: time.Now(),
})
}

View File

@ -3,7 +3,7 @@ package owl_test
import ( import (
"fmt" "fmt"
"h4kor/owl-blogs" "h4kor/owl-blogs"
"h4kor/owl-blogs/priv/assertions" "h4kor/owl-blogs/test/assertions"
"os" "os"
"path" "path"
"testing" "testing"

16
utils.go Normal file
View File

@ -0,0 +1,16 @@
package owl
import (
"crypto/rand"
"math/big"
)
func GenerateRandomString(length int) string {
chars := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
b := make([]rune, length)
for i := range b {
k, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
b[i] = chars[k.Int64()]
}
return string(b)
}

View File

@ -61,6 +61,7 @@ type HtmlParser interface {
ParseLinks(resp *http.Response) ([]string, error) ParseLinks(resp *http.Response) ([]string, error)
ParseLinksFromString(string) ([]string, error) ParseLinksFromString(string) ([]string, error)
GetWebmentionEndpoint(resp *http.Response) (string, error) GetWebmentionEndpoint(resp *http.Response) (string, error)
GetRedirctUris(resp *http.Response) ([]string, error)
} }
type OwlHttpClient = http.Client type OwlHttpClient = http.Client
@ -243,3 +244,73 @@ func (OwlHtmlParser) GetWebmentionEndpoint(resp *http.Response) (string, error)
} }
return requestUrl.ResolveReference(linkUrl).String(), nil return requestUrl.ResolveReference(linkUrl).String(), nil
} }
func (OwlHtmlParser) GetRedirctUris(resp *http.Response) ([]string, error) {
//request url
requestUrl := resp.Request.URL
htmlStr, err := readResponseBody(resp)
if err != nil {
return make([]string, 0), err
}
doc, err := html.Parse(strings.NewReader(htmlStr))
if err != nil {
return make([]string, 0), err
}
var findLinks func(*html.Node) ([]string, error)
// Check link headers
header_links := make([]string, 0)
for _, linkHeader := range resp.Header["Link"] {
linkHeaderParts := strings.Split(linkHeader, ",")
for _, linkHeaderPart := range linkHeaderParts {
linkHeaderPart = strings.TrimSpace(linkHeaderPart)
params := strings.Split(linkHeaderPart, ";")
if len(params) != 2 {
continue
}
for _, param := range params[1:] {
param = strings.TrimSpace(param)
if strings.Contains(param, "redirect_uri") {
link := strings.Split(params[0], ";")[0]
link = strings.Trim(link, "<>")
linkUrl, err := url.Parse(link)
if err == nil {
header_links = append(header_links, requestUrl.ResolveReference(linkUrl).String())
}
}
}
}
}
findLinks = func(n *html.Node) ([]string, error) {
links := make([]string, 0)
if n.Type == html.ElementNode && n.Data == "link" {
// check for rel="redirect_uri"
rel := ""
href := ""
for _, attr := range n.Attr {
if attr.Key == "href" {
href = attr.Val
}
if attr.Key == "rel" {
rel = attr.Val
}
}
if rel == "redirect_uri" {
linkUrl, err := url.Parse(href)
if err == nil {
links = append(links, requestUrl.ResolveReference(linkUrl).String())
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
childLinks, _ := findLinks(c)
links = append(links, childLinks...)
}
return links, nil
}
body_links, err := findLinks(doc)
return append(body_links, header_links...), err
}

View File

@ -3,7 +3,7 @@ package owl_test
import ( import (
"bytes" "bytes"
"h4kor/owl-blogs" "h4kor/owl-blogs"
"h4kor/owl-blogs/priv/assertions" "h4kor/owl-blogs/test/assertions"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"