mirror of
https://github.com/volatiletech/authboss.git
synced 2024-11-28 08:58:38 +02:00
Re(move) swaths of code
- Document more things - Remove module code - Remove callbacks code - Remove data makers, flash messages, and context providers in exchange for middlewares that use context (unwritten) - Move more implementations (responses, redirector, router) to defaults package - Rename key interfaces (again), Storer -> User, StoreLoader -> ServerStorer (opposite of ClientStateStorer) if this is the last time I rename these I'll be shocked
This commit is contained in:
parent
59b2874bcd
commit
b33e47a97c
56
authboss.go
56
authboss.go
@ -6,60 +6,47 @@ races without having to think about how to store passwords or remember tokens.
|
||||
*/
|
||||
package authboss
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
import "github.com/pkg/errors"
|
||||
|
||||
// Authboss contains a configuration and other details for running.
|
||||
type Authboss struct {
|
||||
Config
|
||||
Callbacks *Callbacks
|
||||
loadedModules map[string]bool
|
||||
|
||||
loadedModules map[string]Modularizer
|
||||
mux *http.ServeMux
|
||||
|
||||
templateNames []string
|
||||
renderer Renderer
|
||||
viewRenderer Renderer
|
||||
mailRenderer Renderer
|
||||
}
|
||||
|
||||
// New makes a new instance of authboss with a default
|
||||
// configuration.
|
||||
func New() *Authboss {
|
||||
ab := &Authboss{
|
||||
Callbacks: NewCallbacks(),
|
||||
loadedModules: make(map[string]Modularizer),
|
||||
}
|
||||
ab := &Authboss{}
|
||||
ab.Config.Defaults()
|
||||
return ab
|
||||
}
|
||||
|
||||
// Init authboss and the requested modules. modulesToLoad is left empty
|
||||
// all registered modules will be loaded.
|
||||
func (a *Authboss) Init(modulesToLoad ...string) error {
|
||||
if len(modulesToLoad) == 0 {
|
||||
modulesToLoad = RegisteredModules()
|
||||
}
|
||||
// Init authboss, modules, renderers
|
||||
func (a *Authboss) Init() error {
|
||||
//TODO(aarondl): Figure the template names out along with new "module" loading.
|
||||
views := []string{"all"}
|
||||
|
||||
for _, name := range modulesToLoad {
|
||||
fmt.Fprintf(a.LogWriter, "%-10s loading\n", "["+name+"]")
|
||||
if err := a.loadModule(name); err != nil {
|
||||
return errors.Wrapf(err, "[%s] error initializing", name)
|
||||
}
|
||||
}
|
||||
|
||||
renderer, err := a.ViewLoader.Init(a.templateNames)
|
||||
var err error
|
||||
a.viewRenderer, err = a.Config.ViewLoader.Init(views)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to init view loader")
|
||||
return errors.Wrap(err, "failed to load the view renderer")
|
||||
}
|
||||
|
||||
a.mailRenderer, err = a.Config.MailViewLoader.Init(views)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to load the mail view renderer")
|
||||
}
|
||||
a.renderer = renderer
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
TODO(aarondl): Fixup
|
||||
|
||||
UpdatePassword should be called to recalculate hashes and do any cleanup
|
||||
that should occur on password resets. Updater should return an error if the
|
||||
update to the user failed (for reasons say like validation, duplicate
|
||||
@ -78,7 +65,6 @@ will be returned.
|
||||
|
||||
The error returned is returned either from the updater if that produced an error
|
||||
or from the cleanup routines.
|
||||
*/
|
||||
func (a *Authboss) UpdatePassword(w http.ResponseWriter, r *http.Request,
|
||||
ptPassword string, user Storer, updater func() error) error {
|
||||
|
||||
@ -101,7 +87,9 @@ func (a *Authboss) UpdatePassword(w http.ResponseWriter, r *http.Request,
|
||||
return nil
|
||||
}
|
||||
|
||||
return a.Callbacks.FireAfter(EventPasswordReset, r.Context())*/
|
||||
return a.Callbacks.FireAfter(EventPasswordReset, r.Context())
|
||||
// TODO(aarondl): Fix
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
*/
|
||||
|
@ -32,6 +32,7 @@ const (
|
||||
// ClientStateEventKind is an enum.
|
||||
type ClientStateEventKind int
|
||||
|
||||
// ClientStateEvent kinds
|
||||
const (
|
||||
ClientStateEventPut ClientStateEventKind = iota
|
||||
ClientStateEventDel
|
||||
@ -71,7 +72,7 @@ type ClientState interface {
|
||||
Get(key string) (string, bool)
|
||||
}
|
||||
|
||||
// clientStateResponseWriter is used to write out the client state at the last
|
||||
// ClientStateResponseWriter is used to write out the client state at the last
|
||||
// moment before the response code is written.
|
||||
type ClientStateResponseWriter struct {
|
||||
ab *Authboss
|
||||
@ -83,7 +84,8 @@ type ClientStateResponseWriter struct {
|
||||
cookieStateEvents []ClientStateEvent
|
||||
}
|
||||
|
||||
func (a *Authboss) NewResponse(w http.ResponseWriter, r *http.Request) http.ResponseWriter {
|
||||
// NewResponse wraps the ResponseWriter with a ClientStateResponseWriter
|
||||
func (a *Authboss) NewResponse(w http.ResponseWriter, r *http.Request) *ClientStateResponseWriter {
|
||||
return &ClientStateResponseWriter{
|
||||
ab: a,
|
||||
ResponseWriter: w,
|
||||
@ -91,6 +93,7 @@ func (a *Authboss) NewResponse(w http.ResponseWriter, r *http.Request) http.Resp
|
||||
}
|
||||
}
|
||||
|
||||
// LoadClientState loads the state from sessions and cookies into the request context
|
||||
func (a *Authboss) LoadClientState(w http.ResponseWriter, r *http.Request) (*http.Request, error) {
|
||||
if a.SessionStateStorer != nil {
|
||||
state, err := a.SessionStateStorer.ReadState(w, r)
|
||||
|
26
config.go
26
config.go
@ -1,9 +1,7 @@
|
||||
package authboss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -27,22 +25,11 @@ type Config struct {
|
||||
// MailViewLoader loads the templates for mail. If this is nil, it will
|
||||
// fall back to using the Renderer created from the ViewLoader instead.
|
||||
MailViewLoader RenderLoader
|
||||
// LayoutDataMaker is a function that can provide authboss with the layout's
|
||||
// template data. It will be merged with the data being provided for the current
|
||||
// view in order to render the templates.
|
||||
LayoutDataMaker ViewDataMaker
|
||||
|
||||
// OAuth2Providers lists all providers that can be used. See
|
||||
// OAuthProvider documentation for more details.
|
||||
OAuth2Providers map[string]OAuth2Provider
|
||||
|
||||
// ErrorHandler handles would be 500 errors.
|
||||
ErrorHandler http.Handler
|
||||
// BadRequestHandler handles would be 400 errors.
|
||||
BadRequestHandler http.Handler
|
||||
// NotFoundHandler handles would be 404 errors.
|
||||
NotFoundHandler http.Handler
|
||||
|
||||
// AuthLoginOKPath is the redirect path after a successful authentication.
|
||||
AuthLoginOKPath string
|
||||
// AuthLoginFailPath is the redirect path after a failed authentication.
|
||||
@ -86,13 +73,9 @@ type Config struct {
|
||||
// email subjects.
|
||||
EmailSubjectPrefix string
|
||||
|
||||
// XSRFName is the name of the xsrf token to put in the hidden form fields.
|
||||
XSRFName string
|
||||
// XSRFMaker is a function that returns an xsrf token for the current non-POST request.
|
||||
XSRFMaker XSRF
|
||||
|
||||
// Storer is the interface through which Authboss accesses the web apps database.
|
||||
StoreLoader StoreLoader
|
||||
// Storer is the interface through which Authboss accesses the web apps database
|
||||
// for user operations.
|
||||
Storer ServerStorer
|
||||
|
||||
// CookieStateStorer must be defined to provide an interface capapable of
|
||||
// storing cookies for the given response, and reading them from the request.
|
||||
@ -108,9 +91,6 @@ type Config struct {
|
||||
// Mailer is the mailer being used to send e-mails out. Authboss defines two loggers for use
|
||||
// LogMailer and SMTPMailer, the default is a LogMailer to io.Discard.
|
||||
Mailer Mailer
|
||||
|
||||
// ContextProvider provides a context for a given request
|
||||
ContextProvider func(*http.Request) context.Context
|
||||
}
|
||||
|
||||
// Defaults sets the configuration's default values.
|
||||
|
42
context.go
42
context.go
@ -13,6 +13,11 @@ const (
|
||||
|
||||
ctxKeySessionState contextKey = "session"
|
||||
ctxKeyCookieState contextKey = "cookie"
|
||||
|
||||
// CTXKeyData is a context key for the accumulating
|
||||
// map[string]interface{} (authboss.HTMLData) to pass to the
|
||||
// renderer
|
||||
CTXKeyData contextKey = "data"
|
||||
)
|
||||
|
||||
func (c contextKey) String() string {
|
||||
@ -25,11 +30,6 @@ func (a *Authboss) CurrentUserID(w http.ResponseWriter, r *http.Request) (string
|
||||
return pid.(string), nil
|
||||
}
|
||||
|
||||
_, err := a.Callbacks.FireBefore(EventGetUserSession, r.Context())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
pid, _ := GetSession(r, SessionKey)
|
||||
return pid, nil
|
||||
}
|
||||
@ -48,9 +48,9 @@ func (a *Authboss) CurrentUserIDP(w http.ResponseWriter, r *http.Request) string
|
||||
}
|
||||
|
||||
// CurrentUser retrieves the current user from the session and the database.
|
||||
func (a *Authboss) CurrentUser(w http.ResponseWriter, r *http.Request) (Storer, error) {
|
||||
func (a *Authboss) CurrentUser(w http.ResponseWriter, r *http.Request) (User, error) {
|
||||
if user := r.Context().Value(ctxKeyUser); user != nil {
|
||||
return user.(Storer), nil
|
||||
return user.(User), nil
|
||||
}
|
||||
|
||||
pid, err := a.CurrentUserID(w, r)
|
||||
@ -65,7 +65,7 @@ func (a *Authboss) CurrentUser(w http.ResponseWriter, r *http.Request) (Storer,
|
||||
|
||||
// CurrentUserP retrieves the current user but panics if it's not available for
|
||||
// any reason.
|
||||
func (a *Authboss) CurrentUserP(w http.ResponseWriter, r *http.Request) Storer {
|
||||
func (a *Authboss) CurrentUserP(w http.ResponseWriter, r *http.Request) User {
|
||||
i, err := a.CurrentUser(w, r)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@ -75,19 +75,8 @@ func (a *Authboss) CurrentUserP(w http.ResponseWriter, r *http.Request) Storer {
|
||||
return i
|
||||
}
|
||||
|
||||
func (a *Authboss) currentUser(ctx context.Context, pid string) (Storer, error) {
|
||||
_, err := a.Callbacks.FireBefore(EventGetUser, ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := a.StoreLoader.Load(ctx, pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, ctxKeyUser, user)
|
||||
err = a.Callbacks.FireAfter(EventGetUser, ctx)
|
||||
func (a *Authboss) currentUser(ctx context.Context, pid string) (User, error) {
|
||||
user, err := a.Storer.Load(ctx, pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -95,7 +84,7 @@ func (a *Authboss) currentUser(ctx context.Context, pid string) (Storer, error)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// LoadCurrentUser takes a pointer to a pointer to the request in order to
|
||||
// LoadCurrentUserID takes a pointer to a pointer to the request in order to
|
||||
// change the current method's request pointer itself to the new request that
|
||||
// contains the new context that has the pid in it.
|
||||
func (a *Authboss) LoadCurrentUserID(w http.ResponseWriter, r **http.Request) (string, error) {
|
||||
@ -118,6 +107,7 @@ func (a *Authboss) LoadCurrentUserID(w http.ResponseWriter, r **http.Request) (s
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// LoadCurrentUserIDP loads the current user id and panics if it's not found
|
||||
func (a *Authboss) LoadCurrentUserIDP(w http.ResponseWriter, r **http.Request) string {
|
||||
pid, err := a.LoadCurrentUserID(w, r)
|
||||
if err != nil {
|
||||
@ -133,9 +123,9 @@ func (a *Authboss) LoadCurrentUserIDP(w http.ResponseWriter, r **http.Request) s
|
||||
// change the current method's request pointer itself to the new request that
|
||||
// contains the new context that has the user in it. Calls LoadCurrentUserID
|
||||
// so the primary id is also put in the context.
|
||||
func (a *Authboss) LoadCurrentUser(w http.ResponseWriter, r **http.Request) (Storer, error) {
|
||||
func (a *Authboss) LoadCurrentUser(w http.ResponseWriter, r **http.Request) (User, error) {
|
||||
if user := (*r).Context().Value(ctxKeyUser); user != nil {
|
||||
return user.(Storer), nil
|
||||
return user.(User), nil
|
||||
}
|
||||
|
||||
pid, err := a.LoadCurrentUserID(w, r)
|
||||
@ -158,7 +148,9 @@ func (a *Authboss) LoadCurrentUser(w http.ResponseWriter, r **http.Request) (Sto
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (a *Authboss) LoadCurrentUserP(w http.ResponseWriter, r **http.Request) Storer {
|
||||
// LoadCurrentUserP does the same as LoadCurrentUser but panics if
|
||||
// the current user is not found.
|
||||
func (a *Authboss) LoadCurrentUserP(w http.ResponseWriter, r **http.Request) User {
|
||||
user, err := a.LoadCurrentUser(w, r)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
8
defaults/doc.go
Normal file
8
defaults/doc.go
Normal file
@ -0,0 +1,8 @@
|
||||
// Package defaults houses default implementations for the very many
|
||||
// interfaces that authboss has. It's a goal of the defaults package
|
||||
// to provide the core where authboss implements the shell.
|
||||
//
|
||||
// It's simultaneously supposed to be possible to take as many or
|
||||
// as few of these implementations as you desire, allowing you to
|
||||
// reimplement where necessary, but reuse where possible.
|
||||
package defaults
|
140
defaults/responder.go
Normal file
140
defaults/responder.go
Normal file
@ -0,0 +1,140 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/volatiletech/authboss"
|
||||
)
|
||||
|
||||
// Responder helps respond to http requests
|
||||
type Responder struct {
|
||||
// CRSFHandler creates csrf tokens for inclusion on rendered forms
|
||||
CSRFMaker CSRFMaker
|
||||
// CRSFName is the name of the field that will include the token
|
||||
CSRFName string
|
||||
|
||||
Renderer authboss.Renderer
|
||||
}
|
||||
|
||||
// CSRFMaker returns an opaque string when handed a request and response
|
||||
// to be included in the data as a
|
||||
type CSRFMaker func(w http.ResponseWriter, r *http.Request) string
|
||||
|
||||
// Respond to an HTTP request. Renders templates, flash messages, does CSRF
|
||||
// and writes the headers out.
|
||||
func (r *Responder) Respond(w http.ResponseWriter, req *http.Request, code int, templateName string, data authboss.HTMLData) error {
|
||||
data.MergeKV(
|
||||
r.CSRFName, r.CSRFMaker(w, req),
|
||||
)
|
||||
|
||||
/*
|
||||
TODO(aarondl): Add middlewares for accumulating eventual view data using contexts
|
||||
if a.LayoutDataMaker != nil {
|
||||
data.Merge(a.LayoutDataMaker(w, req))
|
||||
}
|
||||
|
||||
flashSuccess := authboss.FlashSuccess(w, req)
|
||||
flashError := authboss.FlashError(w, req)
|
||||
if len(flashSuccess) != 0 {
|
||||
data.MergeKV(authboss.FlashSuccessKey, flashSuccess)
|
||||
}
|
||||
if len(flashError) != 0 {
|
||||
data.MergeKV(authboss.FlashErrorKey, flashError)
|
||||
}
|
||||
*/
|
||||
|
||||
rendered, mime, err := r.Renderer.Render(req.Context(), templateName, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.WriteHeader(code)
|
||||
|
||||
_, err = w.Write(rendered)
|
||||
return err
|
||||
}
|
||||
|
||||
func isAPIRequest(r *http.Request) bool {
|
||||
return r.Header.Get("Content-Type") == "application/json"
|
||||
}
|
||||
|
||||
// Redirector for http requests
|
||||
type Redirector struct {
|
||||
// FormValueName for the redirection
|
||||
FormValueName string
|
||||
|
||||
Renderer authboss.Renderer
|
||||
}
|
||||
|
||||
// Redirect the client elsewhere. If it's an API request it will simply render
|
||||
// a JSON response with information that should help a client to decide what
|
||||
// to do.
|
||||
func (r *Redirector) Redirect(w http.ResponseWriter, req *http.Request, ro authboss.RedirectOptions) error {
|
||||
var redirectFunction = r.redirectNonAPI
|
||||
if isAPIRequest(req) {
|
||||
redirectFunction = r.redirectAPI
|
||||
}
|
||||
|
||||
return redirectFunction(w, req, ro)
|
||||
}
|
||||
|
||||
func (r Redirector) redirectAPI(w http.ResponseWriter, req *http.Request, ro authboss.RedirectOptions) error {
|
||||
path := ro.RedirectPath
|
||||
redir := req.FormValue(r.FormValueName)
|
||||
if len(redir) != 0 && ro.FollowRedirParam {
|
||||
path = redir
|
||||
}
|
||||
|
||||
var status, message string
|
||||
if len(ro.Success) != 0 {
|
||||
status = "success"
|
||||
message = ro.Success
|
||||
}
|
||||
if len(ro.Failure) != 0 {
|
||||
status = "failure"
|
||||
message = ro.Failure
|
||||
}
|
||||
|
||||
data := authboss.HTMLData{
|
||||
"location": path,
|
||||
}
|
||||
|
||||
if len(status) != 0 {
|
||||
data["status"] = status
|
||||
data["message"] = message
|
||||
}
|
||||
|
||||
body, mime, err := r.Renderer.Render(req.Context(), "redirect", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(body) != 0 {
|
||||
w.Header().Set("Content-Type", mime)
|
||||
}
|
||||
|
||||
if ro.Code != 0 {
|
||||
w.WriteHeader(ro.Code)
|
||||
}
|
||||
_, err = w.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r Redirector) redirectNonAPI(w http.ResponseWriter, req *http.Request, ro authboss.RedirectOptions) error {
|
||||
path := ro.RedirectPath
|
||||
redir := req.FormValue(r.FormValueName)
|
||||
if len(redir) != 0 && ro.FollowRedirParam {
|
||||
path = redir
|
||||
}
|
||||
|
||||
if len(ro.Success) != 0 {
|
||||
authboss.PutSession(w, authboss.FlashSuccessKey, ro.Success)
|
||||
}
|
||||
if len(ro.Failure) != 0 {
|
||||
authboss.PutSession(w, authboss.FlashErrorKey, ro.Failure)
|
||||
}
|
||||
|
||||
http.Redirect(w, req, path, http.StatusFound)
|
||||
return nil
|
||||
}
|
242
defaults/responder_test.go
Normal file
242
defaults/responder_test.go
Normal file
@ -0,0 +1,242 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/volatiletech/authboss"
|
||||
"github.com/volatiletech/authboss/internal/mocks"
|
||||
)
|
||||
|
||||
type testRenderer struct {
|
||||
Callback func(context.Context, string, authboss.HTMLData) ([]byte, string, error)
|
||||
}
|
||||
|
||||
func (t testRenderer) Render(ctx context.Context, name string, data authboss.HTMLData) ([]byte, string, error) {
|
||||
return t.Callback(ctx, name, data)
|
||||
}
|
||||
|
||||
func TestResponder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
renderer := testRenderer{
|
||||
Callback: func(ctx context.Context, name string, data authboss.HTMLData) ([]byte, string, error) {
|
||||
return nil, "", nil
|
||||
},
|
||||
}
|
||||
|
||||
responder := Responder{
|
||||
Renderer: renderer,
|
||||
CSRFName: "csrf",
|
||||
CSRFMaker: func(w http.ResponseWriter, r *http.Request) string { return "csrftoken" },
|
||||
}
|
||||
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := responder.Respond(w, r, http.StatusCreated, "some_template.tpl", authboss.HTMLData{"auth_happy": true})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Error("code was wrong:", w.Code)
|
||||
}
|
||||
|
||||
if got := w.HeaderMap.Get("Content-Type"); got != "application/json" {
|
||||
t.Error("content type was wrong:", got)
|
||||
}
|
||||
|
||||
expectData := authboss.HTMLData{
|
||||
"csrfName": "xsrf",
|
||||
"csrfToken": "xsrftoken",
|
||||
"hello": "world",
|
||||
"auth_happy": true,
|
||||
}
|
||||
|
||||
var gotData authboss.HTMLData
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &gotData); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(gotData, expectData) {
|
||||
t.Errorf("data mismatched:\nwant: %#v\ngot: %#v", expectData, gotData)
|
||||
}
|
||||
}
|
||||
func TestRedirector(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
renderer := testRenderer{
|
||||
Callback: func(ctx context.Context, name string, data authboss.HTMLData) ([]byte, string, error) {
|
||||
return nil, "", nil
|
||||
},
|
||||
}
|
||||
|
||||
redir := Redirector{
|
||||
FormValueName: "redir",
|
||||
Renderer: renderer,
|
||||
}
|
||||
|
||||
r := httptest.NewRequest("POST", "/?redir=/pow", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
|
||||
ro := authboss.RedirectOptions{
|
||||
Success: "ok!",
|
||||
Code: http.StatusTeapot,
|
||||
RedirectPath: "/redirect", FollowRedirParam: false,
|
||||
}
|
||||
|
||||
if err := redir.Redirect(w, r, ro); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if w.Code != http.StatusTeapot {
|
||||
t.Error("code is wrong:", w.Code)
|
||||
}
|
||||
|
||||
var gotData map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &gotData); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if got := gotData["status"]; got != "success" {
|
||||
t.Error("status was wrong:", got)
|
||||
}
|
||||
if got := gotData["message"]; got != "ok!" {
|
||||
t.Error("message was wrong:", got)
|
||||
}
|
||||
if got := gotData["location"]; got != "/redirect" {
|
||||
t.Error("location was wrong:", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseRedirectAPIFollowRedir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
renderer := testRenderer{
|
||||
Callback: func(ctx context.Context, name string, data authboss.HTMLData) ([]byte, string, error) {
|
||||
return nil, "", nil
|
||||
},
|
||||
}
|
||||
|
||||
redir := Redirector{
|
||||
FormValueName: "redir",
|
||||
Renderer: renderer,
|
||||
}
|
||||
|
||||
r := httptest.NewRequest("POST", "/?redir=/pow", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
|
||||
ro := authboss.RedirectOptions{
|
||||
Failure: ":(",
|
||||
Code: http.StatusTeapot,
|
||||
RedirectPath: "/redirect", FollowRedirParam: true,
|
||||
}
|
||||
|
||||
if err := redir.Redirect(w, r, ro); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if w.Code != http.StatusTeapot {
|
||||
t.Error("code is wrong:", w.Code)
|
||||
}
|
||||
|
||||
var gotData map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &gotData); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if got := gotData["status"]; got != "failure" {
|
||||
t.Error("status was wrong:", got)
|
||||
}
|
||||
if got := gotData["message"]; got != ":(" {
|
||||
t.Error("message was wrong:", got)
|
||||
}
|
||||
if got := gotData["location"]; got != "/pow" {
|
||||
t.Error("location was wrong:", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseRedirectNonAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
renderer := testRenderer{
|
||||
Callback: func(ctx context.Context, name string, data authboss.HTMLData) ([]byte, string, error) {
|
||||
return nil, "", nil
|
||||
},
|
||||
}
|
||||
|
||||
redir := Redirector{
|
||||
FormValueName: "redir",
|
||||
Renderer: renderer,
|
||||
}
|
||||
|
||||
r := httptest.NewRequest("POST", "/?redir=/pow", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ab := authboss.New()
|
||||
ab.Config.SessionStateStorer = mocks.NewClientRW()
|
||||
ab.Config.CookieStateStorer = mocks.NewClientRW()
|
||||
aw := ab.NewResponse(w, r)
|
||||
|
||||
ro := authboss.RedirectOptions{
|
||||
Success: "success", Failure: "failure",
|
||||
RedirectPath: "/redirect", FollowRedirParam: false,
|
||||
}
|
||||
|
||||
if err := redir.Redirect(aw, r, ro); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Error("code is wrong:", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Location"); got != "/redirect" {
|
||||
t.Error("redirect location was wrong:", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseRedirectNonAPIFollowRedir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
renderer := testRenderer{
|
||||
Callback: func(ctx context.Context, name string, data authboss.HTMLData) ([]byte, string, error) {
|
||||
return nil, "", nil
|
||||
},
|
||||
}
|
||||
|
||||
redir := Redirector{
|
||||
FormValueName: "redir",
|
||||
Renderer: renderer,
|
||||
}
|
||||
|
||||
r := httptest.NewRequest("POST", "/?redir=/pow", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ab := authboss.New()
|
||||
ab.Config.SessionStateStorer = mocks.NewClientRW()
|
||||
ab.Config.CookieStateStorer = mocks.NewClientRW()
|
||||
aw := ab.NewResponse(w, r)
|
||||
|
||||
ro := authboss.RedirectOptions{
|
||||
RedirectPath: "/redirect", FollowRedirParam: true,
|
||||
}
|
||||
if err := redir.Redirect(aw, r, ro); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Error("code is wrong:", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("Location"); got != "/pow" {
|
||||
t.Error("redirect location was wrong:", got)
|
||||
}
|
||||
}
|
65
defaults/router.go
Normal file
65
defaults/router.go
Normal file
@ -0,0 +1,65 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Router implementation
|
||||
// Does not use a dynamic map to hope to be slightly more performant
|
||||
type Router struct {
|
||||
gets *http.ServeMux
|
||||
posts *http.ServeMux
|
||||
deletes *http.ServeMux
|
||||
}
|
||||
|
||||
// NewRouter creates a new router
|
||||
func NewRouter() *Router {
|
||||
r := &Router{
|
||||
gets: http.NewServeMux(),
|
||||
posts: http.NewServeMux(),
|
||||
deletes: http.NewServeMux(),
|
||||
}
|
||||
|
||||
// Nothing gets handled at the root of the authboss router
|
||||
r.gets.Handle("/", http.NotFoundHandler())
|
||||
r.posts.Handle("/", http.NotFoundHandler())
|
||||
r.deletes.Handle("/", http.NotFoundHandler())
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// Get method route
|
||||
func (r *Router) Get(path string, handler http.Handler) {
|
||||
r.gets.Handle(path, handler)
|
||||
}
|
||||
|
||||
// Post method route
|
||||
func (r *Router) Post(path string, handler http.Handler) {
|
||||
r.posts.Handle(path, handler)
|
||||
}
|
||||
|
||||
// Delete method route
|
||||
func (r *Router) Delete(path string, handler http.Handler) {
|
||||
r.deletes.Handle(path, handler)
|
||||
}
|
||||
|
||||
// ServeHTTP for http.Handler
|
||||
// Only does get/posts, all other request types are a bad request
|
||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
var router http.Handler
|
||||
switch req.Method {
|
||||
case "GET":
|
||||
router = r.gets
|
||||
case "POST":
|
||||
router = r.posts
|
||||
case "DELETE":
|
||||
router = r.deletes
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
io.WriteString(w, "bad request, this method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
}
|
65
defaults/router_test.go
Normal file
65
defaults/router_test.go
Normal file
@ -0,0 +1,65 @@
|
||||
package defaults
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRouter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRouter()
|
||||
var get, post, delete string
|
||||
wantGet, wantPost, wantDelete := "testget", "testpost", "testdelete"
|
||||
|
||||
r.Get("/test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
get = string(b)
|
||||
}))
|
||||
r.Post("/test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
post = string(b)
|
||||
}))
|
||||
r.Delete("/test", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
delete = string(b)
|
||||
}))
|
||||
|
||||
if get != wantGet {
|
||||
t.Error("want:", wantGet, "got:", get)
|
||||
}
|
||||
if post != wantPost {
|
||||
t.Error("want:", wantPost, "got:", post)
|
||||
}
|
||||
if delete != wantDelete {
|
||||
t.Error("want:", wantDelete, "got:", delete)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterBadMethod(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := NewRouter()
|
||||
wr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("OPTIONS", "/", nil)
|
||||
|
||||
r.ServeHTTP(wr, req)
|
||||
|
||||
if wr.Code != http.StatusBadRequest {
|
||||
t.Error("want bad request code, got:", wr.Code)
|
||||
}
|
||||
}
|
@ -103,15 +103,15 @@ func (m *User) SetOAuthExpiry(ctx context.Context, oAuthExpiry time.Time) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// StoreLoader should be valid for any module storer defined in authboss.
|
||||
type StoreLoader struct {
|
||||
// ServerStorer should be valid for any module storer defined in authboss.
|
||||
type ServerStorer struct {
|
||||
Users map[string]*User
|
||||
RMTokens map[string][]string
|
||||
}
|
||||
|
||||
// NewStoreLoader constructor
|
||||
func NewStoreLoader() *StoreLoader {
|
||||
return &StoreLoader{
|
||||
// NewServerStorer constructor
|
||||
func NewServerStorer() *ServerStorer {
|
||||
return &ServerStorer{
|
||||
Users: make(map[string]*User),
|
||||
RMTokens: make(map[string][]string),
|
||||
}
|
||||
@ -220,15 +220,15 @@ func (FailStorer) Load(context.Context) error {
|
||||
return errors.New("fail storer: get")
|
||||
}
|
||||
|
||||
// ClientStorer is used for testing the client stores on context
|
||||
type ClientStorer struct {
|
||||
// ClientRW is used for testing the client stores on context
|
||||
type ClientState struct {
|
||||
Values map[string]string
|
||||
GetShouldFail bool
|
||||
}
|
||||
|
||||
// NewClientStorer constructs a ClientStorer
|
||||
func NewClientStorer(data ...string) *ClientStorer {
|
||||
if len(data)%2 != 0 {
|
||||
// NewClientState constructs a ClientStorer
|
||||
func NewClientState(data ...string) *ClientState {
|
||||
if len(data) != 0 && len(data)%2 != 0 {
|
||||
panic("It should be a key value list of arguments.")
|
||||
}
|
||||
|
||||
@ -238,11 +238,11 @@ func NewClientStorer(data ...string) *ClientStorer {
|
||||
values[data[i]] = data[i+1]
|
||||
}
|
||||
|
||||
return &ClientStorer{Values: values}
|
||||
return &ClientState{Values: values}
|
||||
}
|
||||
|
||||
// Get a key's value
|
||||
func (m *ClientStorer) Get(key string) (string, bool) {
|
||||
func (m *ClientState) Get(key string) (string, bool) {
|
||||
if m.GetShouldFail {
|
||||
return "", false
|
||||
}
|
||||
@ -251,24 +251,35 @@ func (m *ClientStorer) Get(key string) (string, bool) {
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// GetErr gets a key's value or err if not exist
|
||||
func (m *ClientStorer) GetErr(key string) (string, error) {
|
||||
if m.GetShouldFail {
|
||||
return "", authboss.ClientDataErr{Name: key}
|
||||
}
|
||||
|
||||
v, ok := m.Values[key]
|
||||
if !ok {
|
||||
return v, authboss.ClientDataErr{Name: key}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Put a value
|
||||
func (m *ClientStorer) Put(key, val string) { m.Values[key] = val }
|
||||
func (m *ClientState) Put(key, val string) { m.Values[key] = val }
|
||||
|
||||
// Del a key/value pair
|
||||
func (m *ClientStorer) Del(key string) { delete(m.Values, key) }
|
||||
func (m *ClientState) Del(key string) { delete(m.Values, key) }
|
||||
|
||||
// ClientStateRW stores things that would originally
|
||||
// go in a session, or a map, in memory!
|
||||
type ClientStateRW struct {
|
||||
ClientValues map[string]string
|
||||
}
|
||||
|
||||
// NewClientRW takes the data from a client state
|
||||
// and returns.
|
||||
func NewClientRW() *ClientStateRW {
|
||||
return &ClientStateRW{
|
||||
ClientValues: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadState from memory
|
||||
func (c *ClientStateRW) ReadState(http.ResponseWriter, *http.Request) (authboss.ClientState, error) {
|
||||
return &ClientState{Values: c.ClientValues}, nil
|
||||
}
|
||||
|
||||
// WriteState to memory
|
||||
func (c *ClientStateRW) WriteState(w http.ResponseWriter, cstate authboss.ClientState, cse []authboss.ClientStateEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Request returns a new request with optional key-value body (form-post)
|
||||
func Request(method string, postKeyValues ...string) *http.Request {
|
||||
|
79
module.go
79
module.go
@ -1,79 +0,0 @@
|
||||
package authboss
|
||||
|
||||
import "reflect"
|
||||
|
||||
var registeredModules = make(map[string]Modularizer)
|
||||
|
||||
// Modularizer should be implemented by all the authboss modules.
|
||||
type Modularizer interface {
|
||||
Initialize(*Authboss) error
|
||||
Routes() RouteTable
|
||||
Templates() []string
|
||||
}
|
||||
|
||||
// RegisterModule with the core providing all the necessary information to
|
||||
// integrate into authboss.
|
||||
func RegisterModule(name string, m Modularizer) {
|
||||
registeredModules[name] = m
|
||||
}
|
||||
|
||||
// RegisteredModules returns a list of modules that are currently registered.
|
||||
func RegisteredModules() []string {
|
||||
mods := make([]string, len(registeredModules))
|
||||
i := 0
|
||||
for k := range registeredModules {
|
||||
mods[i] = k
|
||||
i++
|
||||
}
|
||||
|
||||
return mods
|
||||
}
|
||||
|
||||
// loadModule loads a particular module. It uses reflection to create a new
|
||||
// instance of the module type. The original value is copied, but not deep copied
|
||||
// so care should be taken to make sure most initialization happens inside the Initialize()
|
||||
// method of the module.
|
||||
func (a *Authboss) loadModule(name string) error {
|
||||
module, ok := registeredModules[name]
|
||||
if !ok {
|
||||
panic("Could not find module: " + name)
|
||||
}
|
||||
|
||||
var wasPtr bool
|
||||
modVal := reflect.ValueOf(module)
|
||||
if modVal.Kind() == reflect.Ptr {
|
||||
wasPtr = true
|
||||
modVal = modVal.Elem()
|
||||
}
|
||||
|
||||
modType := modVal.Type()
|
||||
value := reflect.New(modType)
|
||||
if !wasPtr {
|
||||
value = value.Elem()
|
||||
value.Set(modVal)
|
||||
} else {
|
||||
value.Elem().Set(modVal)
|
||||
}
|
||||
mod, ok := value.Interface().(Modularizer)
|
||||
a.loadedModules[name] = mod
|
||||
a.templateNames = append(a.templateNames, mod.Templates()...)
|
||||
return mod.Initialize(a)
|
||||
}
|
||||
|
||||
// LoadedModules returns a list of modules that are currently loaded.
|
||||
func (a *Authboss) LoadedModules() []string {
|
||||
mods := make([]string, len(a.loadedModules))
|
||||
i := 0
|
||||
for k := range a.loadedModules {
|
||||
mods[i] = k
|
||||
i++
|
||||
}
|
||||
|
||||
return mods
|
||||
}
|
||||
|
||||
// IsLoaded checks if a specific module is loaded.
|
||||
func (a *Authboss) IsLoaded(mod string) bool {
|
||||
_, ok := a.loadedModules[mod]
|
||||
return ok
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
package authboss
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const testModName = "testmodule"
|
||||
|
||||
func init() {
|
||||
RegisterModule(testModName, testMod)
|
||||
}
|
||||
|
||||
type testModule struct {
|
||||
r RouteTable
|
||||
}
|
||||
|
||||
var testMod = &testModule{
|
||||
r: RouteTable{
|
||||
"/testroute": testHandler,
|
||||
},
|
||||
}
|
||||
|
||||
func testHandler(w http.ResponseWriter, r *http.Request) error {
|
||||
w.Header().Set("testhandler", "test")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *testModule) Initialize(a *Authboss) error { return nil }
|
||||
func (t *testModule) Routes() RouteTable { return t.r }
|
||||
func (t *testModule) Templates() []string { return []string{"template1.tpl"} }
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
// RegisterModule called by init()
|
||||
if _, ok := registeredModules[testModName]; !ok {
|
||||
t.Error("Expected module to be saved.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadedModules(t *testing.T) {
|
||||
// RegisterModule called by init()
|
||||
registered := RegisteredModules()
|
||||
if len(registered) != 2 { // There is another test module loaded from router
|
||||
t.Error("Expected only a single module to be loaded.")
|
||||
} else {
|
||||
found := false
|
||||
for _, name := range registered {
|
||||
if name == testModName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("It should have found the module:", registered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLoaded(t *testing.T) {
|
||||
ab := New()
|
||||
ab.LogWriter = ioutil.Discard
|
||||
ab.ViewLoader = mockRenderLoader{}
|
||||
if err := ab.Init(testModName); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if loaded := ab.LoadedModules(); len(loaded) == 0 || loaded[0] != testModName {
|
||||
t.Error("Loaded modules wrong:", loaded)
|
||||
}
|
||||
}
|
172
response.go
172
response.go
@ -6,69 +6,6 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Respond to an HTTP request. Renders templates, flash messages, does XSRF
|
||||
// and writes the headers out.
|
||||
func (a *Authboss) Respond(w http.ResponseWriter, r *http.Request, code int, templateName string, data HTMLData) error {
|
||||
data.MergeKV(
|
||||
"xsrfName", a.XSRFName,
|
||||
"xsrfToken", a.XSRFMaker(w, r),
|
||||
)
|
||||
|
||||
if a.LayoutDataMaker != nil {
|
||||
data.Merge(a.LayoutDataMaker(w, r))
|
||||
}
|
||||
|
||||
flashSuccess := FlashSuccess(w, r)
|
||||
flashError := FlashError(w, r)
|
||||
if len(flashSuccess) != 0 {
|
||||
data.MergeKV(FlashSuccessKey, flashSuccess)
|
||||
}
|
||||
if len(flashError) != 0 {
|
||||
data.MergeKV(FlashErrorKey, flashError)
|
||||
}
|
||||
|
||||
rendered, mime, err := a.renderer.Render(r.Context(), templateName, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.WriteHeader(code)
|
||||
|
||||
_, err = w.Write(rendered)
|
||||
return err
|
||||
}
|
||||
|
||||
// EmailResponseOptions controls how e-mails are rendered and sent
|
||||
type EmailResponseOptions struct {
|
||||
Data HTMLData
|
||||
HTMLTemplate string
|
||||
TextTemplate string
|
||||
}
|
||||
|
||||
// Email renders the e-mail templates and sends it using the mailer.
|
||||
func (a *Authboss) Email(w http.ResponseWriter, r *http.Request, email Email, ro EmailResponseOptions) error {
|
||||
ctx := r.Context()
|
||||
|
||||
if len(ro.HTMLTemplate) != 0 {
|
||||
htmlBody, _, err := a.renderer.Render(ctx, ro.HTMLTemplate, ro.Data)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to render e-mail html body")
|
||||
}
|
||||
email.HTMLBody = string(htmlBody)
|
||||
}
|
||||
|
||||
if len(ro.TextTemplate) != 0 {
|
||||
textBody, _, err := a.renderer.Render(ctx, ro.TextTemplate, ro.Data)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to render e-mail text body")
|
||||
}
|
||||
email.TextBody = string(textBody)
|
||||
}
|
||||
|
||||
return a.Mailer.Send(ctx, email)
|
||||
}
|
||||
|
||||
// RedirectOptions packages up all the pieces a module needs to write out a
|
||||
// response.
|
||||
type RedirectOptions struct {
|
||||
@ -92,74 +29,51 @@ type RedirectOptions struct {
|
||||
FollowRedirParam bool
|
||||
}
|
||||
|
||||
// Redirect the client elsewhere. If it's an API request it will simply render
|
||||
// a JSON response with information that should help a client to decide what
|
||||
// to do.
|
||||
func (a *Authboss) Redirect(w http.ResponseWriter, r *http.Request, ro RedirectOptions) error {
|
||||
var redirectFunction = a.redirectNonAPI
|
||||
if isAPIRequest(r) {
|
||||
redirectFunction = a.redirectAPI
|
||||
}
|
||||
|
||||
return redirectFunction(w, r, ro)
|
||||
// EmailResponseOptions controls how e-mails are rendered and sent
|
||||
type EmailResponseOptions struct {
|
||||
Data HTMLData
|
||||
HTMLTemplate string
|
||||
TextTemplate string
|
||||
}
|
||||
|
||||
func (a *Authboss) redirectAPI(w http.ResponseWriter, r *http.Request, ro RedirectOptions) error {
|
||||
path := ro.RedirectPath
|
||||
redir := r.FormValue(FormValueRedirect)
|
||||
if len(redir) != 0 && ro.FollowRedirParam {
|
||||
path = redir
|
||||
}
|
||||
|
||||
var status, message string
|
||||
if len(ro.Success) != 0 {
|
||||
status = "success"
|
||||
message = ro.Success
|
||||
}
|
||||
if len(ro.Failure) != 0 {
|
||||
status = "failure"
|
||||
message = ro.Failure
|
||||
}
|
||||
|
||||
data := HTMLData{
|
||||
"location": path,
|
||||
}
|
||||
|
||||
if len(status) != 0 {
|
||||
data["status"] = status
|
||||
data["message"] = message
|
||||
}
|
||||
|
||||
body, mime, err := a.renderer.Render(r.Context(), "redirect", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(body) != 0 {
|
||||
w.Header().Set("Content-Type", mime)
|
||||
}
|
||||
|
||||
if ro.Code != 0 {
|
||||
w.WriteHeader(ro.Code)
|
||||
}
|
||||
_, err = w.Write(body)
|
||||
return err
|
||||
// HTTPResponder knows how to respond to an HTTP request
|
||||
// Must consider:
|
||||
// - Flash messages
|
||||
// - XSRF handling (template data)
|
||||
// - Assembling template data from various sources
|
||||
//
|
||||
// Authboss controller methods (like the one called in response to POST /auth/login)
|
||||
// will call this method to write a response to the user.
|
||||
type HTTPResponder interface {
|
||||
Respond(w http.ResponseWriter, r *http.Request, code int, templateName string, data HTMLData) error
|
||||
}
|
||||
|
||||
func (a *Authboss) redirectNonAPI(w http.ResponseWriter, r *http.Request, ro RedirectOptions) error {
|
||||
path := ro.RedirectPath
|
||||
redir := r.FormValue(FormValueRedirect)
|
||||
if len(redir) != 0 && ro.FollowRedirParam {
|
||||
path = redir
|
||||
}
|
||||
|
||||
if len(ro.Success) != 0 {
|
||||
PutSession(w, FlashSuccessKey, ro.Success)
|
||||
}
|
||||
if len(ro.Failure) != 0 {
|
||||
PutSession(w, FlashErrorKey, ro.Failure)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, path, http.StatusFound)
|
||||
return nil
|
||||
// Redirector redirects http requests to a different url (must handle both json and html)
|
||||
// When an authboss controller wants to redirect a user to a different path, it will use
|
||||
// this interface.
|
||||
type Redirector interface {
|
||||
Redirect(w http.ResponseWriter, r *http.Request, ro RedirectOptions) error
|
||||
}
|
||||
|
||||
// Email renders the e-mail templates and sends it using the mailer.
|
||||
func (a *Authboss) Email(w http.ResponseWriter, r *http.Request, email Email, ro EmailResponseOptions) error {
|
||||
ctx := r.Context()
|
||||
|
||||
if len(ro.HTMLTemplate) != 0 {
|
||||
htmlBody, _, err := a.mailRenderer.Render(ctx, ro.HTMLTemplate, ro.Data)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to render e-mail html body")
|
||||
}
|
||||
email.HTMLBody = string(htmlBody)
|
||||
}
|
||||
|
||||
if len(ro.TextTemplate) != 0 {
|
||||
textBody, _, err := a.mailRenderer.Render(ctx, ro.TextTemplate, ro.Data)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to render e-mail text body")
|
||||
}
|
||||
email.TextBody = string(textBody)
|
||||
}
|
||||
|
||||
return a.Mailer.Send(ctx, email)
|
||||
}
|
||||
|
204
response_test.go
204
response_test.go
@ -2,65 +2,22 @@ package authboss
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResponseRespond(t *testing.T) {
|
||||
t.Parallel()
|
||||
type testMailer struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
ab := New()
|
||||
ab.renderer = mockRenderer{expectName: "some_template.tpl"}
|
||||
ab.SessionStateStorer = newMockClientStateRW(
|
||||
FlashSuccessKey, "flash_success",
|
||||
FlashErrorKey, "flash_error",
|
||||
)
|
||||
ab.XSRFName = "xsrf"
|
||||
ab.XSRFMaker = func(w http.ResponseWriter, r *http.Request) string {
|
||||
return "xsrftoken"
|
||||
}
|
||||
ab.LayoutDataMaker = func(w http.ResponseWriter, r *http.Request) HTMLData {
|
||||
return HTMLData{"hello": "world"}
|
||||
}
|
||||
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
wr := httptest.NewRecorder()
|
||||
w := ab.NewResponse(wr, r)
|
||||
r = loadClientStateP(ab, w, r)
|
||||
err := ab.Respond(w, r, http.StatusCreated, "some_template.tpl", HTMLData{"auth_happy": true})
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if wr.Code != http.StatusCreated {
|
||||
t.Error("code was wrong:", wr.Code)
|
||||
}
|
||||
|
||||
if got := wr.HeaderMap.Get("Content-Type"); got != "application/json" {
|
||||
t.Error("content type was wrong:", got)
|
||||
}
|
||||
|
||||
expectData := HTMLData{
|
||||
"xsrfName": "xsrf",
|
||||
"xsrfToken": "xsrftoken",
|
||||
"hello": "world",
|
||||
FlashSuccessKey: "flash_success",
|
||||
FlashErrorKey: "flash_error",
|
||||
"auth_happy": true,
|
||||
}
|
||||
|
||||
var gotData HTMLData
|
||||
if err := json.Unmarshal(wr.Body.Bytes(), &gotData); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(gotData, expectData) {
|
||||
t.Errorf("data mismatched:\nwant: %#v\ngot: %#v", expectData, gotData)
|
||||
}
|
||||
func (t testMailer) Send(_ context.Context, email Email) error {
|
||||
fmt.Fprintf(t.Writer, "%v", email)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResponseEmail(t *testing.T) {
|
||||
@ -81,7 +38,7 @@ func TestResponseEmail(t *testing.T) {
|
||||
}
|
||||
|
||||
output := &bytes.Buffer{}
|
||||
ab.Mailer = LogMailer(output)
|
||||
ab.Mailer = testMailer{output}
|
||||
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
wr := httptest.NewRecorder()
|
||||
@ -113,144 +70,3 @@ func TestResponseEmail(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseRedirectAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ab := New()
|
||||
ab.renderer = mockRenderer{}
|
||||
r := httptest.NewRequest("POST", "/?redir=/pow", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
|
||||
ro := RedirectOptions{
|
||||
Success: "ok!",
|
||||
Code: http.StatusTeapot,
|
||||
RedirectPath: "/redirect", FollowRedirParam: false,
|
||||
}
|
||||
|
||||
if err := ab.Redirect(w, r, ro); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if w.Code != http.StatusTeapot {
|
||||
t.Error("code is wrong:", w.Code)
|
||||
}
|
||||
|
||||
var gotData map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &gotData); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if got := gotData["status"]; got != "success" {
|
||||
t.Error("status was wrong:", got)
|
||||
}
|
||||
if got := gotData["message"]; got != "ok!" {
|
||||
t.Error("message was wrong:", got)
|
||||
}
|
||||
if got := gotData["location"]; got != "/redirect" {
|
||||
t.Error("location was wrong:", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseRedirectAPIFollowRedir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ab := New()
|
||||
ab.renderer = mockRenderer{}
|
||||
r := httptest.NewRequest("POST", "/?redir=/pow", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
|
||||
ro := RedirectOptions{
|
||||
Failure: ":(",
|
||||
Code: http.StatusTeapot,
|
||||
RedirectPath: "/redirect", FollowRedirParam: true,
|
||||
}
|
||||
|
||||
if err := ab.Redirect(w, r, ro); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if w.Code != http.StatusTeapot {
|
||||
t.Error("code is wrong:", w.Code)
|
||||
}
|
||||
|
||||
var gotData map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &gotData); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if got := gotData["status"]; got != "failure" {
|
||||
t.Error("status was wrong:", got)
|
||||
}
|
||||
if got := gotData["message"]; got != ":(" {
|
||||
t.Error("message was wrong:", got)
|
||||
}
|
||||
if got := gotData["location"]; got != "/pow" {
|
||||
t.Error("location was wrong:", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseRedirectNonAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ab := New()
|
||||
r := httptest.NewRequest("POST", "/?redir=/pow", nil)
|
||||
wr := httptest.NewRecorder()
|
||||
w := ab.NewResponse(wr, r)
|
||||
|
||||
ro := RedirectOptions{
|
||||
Success: "success", Failure: "failure",
|
||||
RedirectPath: "/redirect", FollowRedirParam: false,
|
||||
}
|
||||
|
||||
if err := ab.Redirect(w, r, ro); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
csrw := w.(*ClientStateResponseWriter)
|
||||
want := ClientStateEvent{Kind: ClientStateEventPut, Key: FlashSuccessKey, Value: "success"}
|
||||
if csrw.sessionStateEvents[0] != want {
|
||||
t.Error("event was wrong:", csrw.sessionStateEvents[0])
|
||||
}
|
||||
want = ClientStateEvent{Kind: ClientStateEventPut, Key: FlashErrorKey, Value: "failure"}
|
||||
if csrw.sessionStateEvents[1] != want {
|
||||
t.Error("event was wrong:", csrw.sessionStateEvents[1])
|
||||
}
|
||||
if wr.Code != http.StatusFound {
|
||||
t.Error("code is wrong:", wr.Code)
|
||||
}
|
||||
if got := wr.Header().Get("Location"); got != "/redirect" {
|
||||
t.Error("redirect location was wrong:", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseRedirectNonAPIFollowRedir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ab := New()
|
||||
r := httptest.NewRequest("POST", "/?redir=/pow", nil)
|
||||
wr := httptest.NewRecorder()
|
||||
w := ab.NewResponse(wr, r)
|
||||
|
||||
ro := RedirectOptions{
|
||||
RedirectPath: "/redirect", FollowRedirParam: true,
|
||||
}
|
||||
if err := ab.Redirect(w, r, ro); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
csrw := w.(*ClientStateResponseWriter)
|
||||
if len(csrw.sessionStateEvents) != 0 {
|
||||
t.Error("session state events should be empty:", csrw.sessionStateEvents)
|
||||
}
|
||||
if wr.Code != http.StatusFound {
|
||||
t.Error("code is wrong:", wr.Code)
|
||||
}
|
||||
if got := wr.Header().Get("Location"); got != "/pow" {
|
||||
t.Error("redirect location was wrong:", got)
|
||||
}
|
||||
}
|
||||
|
147
router.go
147
router.go
@ -1,148 +1,13 @@
|
||||
package authboss
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
)
|
||||
|
||||
// FormValue constants
|
||||
const (
|
||||
FormValueRedirect = "redir"
|
||||
)
|
||||
|
||||
// HandlerFunc augments http.HandlerFunc with a context and error handling.
|
||||
type HandlerFunc func(http.ResponseWriter, *http.Request) error
|
||||
|
||||
// RouteTable is a routing table from a path to a handlerfunc.
|
||||
type RouteTable map[string]HandlerFunc
|
||||
|
||||
// NewRouter returns a router to be mounted at some mountpoint.
|
||||
func (a *Authboss) NewRouter() http.Handler {
|
||||
if a.mux != nil {
|
||||
return a.mux
|
||||
}
|
||||
a.mux = http.NewServeMux()
|
||||
|
||||
for name, mod := range a.loadedModules {
|
||||
for route, handler := range mod.Routes() {
|
||||
fmt.Fprintf(a.LogWriter, "%-10s Route: %s\n", "["+name+"]", path.Join(a.MountPath, route))
|
||||
a.mux.Handle(path.Join(a.MountPath, route), abHandler{a, handler})
|
||||
}
|
||||
}
|
||||
|
||||
a.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if a.NotFoundHandler != nil {
|
||||
a.NotFoundHandler.ServeHTTP(w, r)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
io.WriteString(w, "404 Page not found")
|
||||
}
|
||||
})
|
||||
|
||||
return a.mux
|
||||
// Router can register routes to later be used by the web application
|
||||
type Router interface {
|
||||
http.Handler
|
||||
Get(path string, handler http.Handler)
|
||||
Post(path string, handler http.Handler)
|
||||
Delete(path string, handler http.Handler)
|
||||
}
|
||||
|
||||
type abHandler struct {
|
||||
*Authboss
|
||||
fn HandlerFunc
|
||||
}
|
||||
|
||||
// TODO(aarondl): Move this somewhere reasonable
|
||||
func isAPIRequest(r *http.Request) bool {
|
||||
return r.Header.Get("Content-Type") == "application/json"
|
||||
}
|
||||
|
||||
func (a abHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Put uid in the context
|
||||
_, err := a.LoadCurrentUserID(w, &r)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
io.WriteString(w, "500 An error has occurred")
|
||||
fmt.Fprintf(a.LogWriter, "failed to load current user id: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the handler
|
||||
err = a.fn(w, r)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Log the error
|
||||
fmt.Fprintf(a.LogWriter, "Error Occurred at %s: %v", r.URL.Path, err)
|
||||
|
||||
// Do specific error handling for special kinds of errors.
|
||||
if _, ok := err.(ClientDataErr); ok {
|
||||
if a.BadRequestHandler != nil {
|
||||
a.BadRequestHandler.ServeHTTP(w, r)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
io.WriteString(w, "400 Bad request")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if a.ErrorHandler != nil {
|
||||
a.ErrorHandler.ServeHTTP(w, r)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
io.WriteString(w, "500 An error has occurred")
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
// TODO(aarondl): Throw away this function
|
||||
// redirectIfLoggedIn checks a user's existence by using currentUser. This is done instead of
|
||||
// a simple Session cookie check so that the remember module has a chance to log the user in
|
||||
// before they are determined to "not be logged in".
|
||||
//
|
||||
// The exceptional routes are sort of hardcoded in a terrible way in here, later on this could move to some
|
||||
// configuration or something more interesting.
|
||||
func redirectIfLoggedIn(w http.ResponseWriter, r *http.Request) (handled bool) {
|
||||
// If it's a log out url, always let it pass through.
|
||||
if strings.HasSuffix(r.URL.Path, "/logout") {
|
||||
return false
|
||||
}
|
||||
|
||||
// If it's an auth url, allow them through if they're half-authed.
|
||||
if strings.HasSuffix(r.URL.Path, "/auth") || strings.Contains(r.URL.Path, "/oauth2/") {
|
||||
if halfAuthed, ok := ctx.SessionStorer.Get(SessionHalfAuthKey); ok && halfAuthed == "true" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, check if they're logged in, this uses hooks to allow remember
|
||||
// to set the session cookie
|
||||
cu, err := ctx.currentUser(ctx, w, r)
|
||||
|
||||
// if the user was not found, that means the user was deleted from the underlying
|
||||
// storer and we should just remove this session cookie and allow them through.
|
||||
// if it's a generic error, 500
|
||||
// if the user is found, redirect them away from this page, because they don't need
|
||||
// to see it.
|
||||
if err == ErrUserNotFound {
|
||||
uname, _ := ctx.SessionStorer.Get(SessionKey)
|
||||
fmt.Fprintf(ctx.LogWriter, "user (%s) has session cookie but user not found, removing cookie", uname)
|
||||
ctx.SessionStorer.Del(SessionKey)
|
||||
return false
|
||||
} else if err != nil {
|
||||
fmt.Fprintf(ctx.LogWriter, "error occurred reading current user at %s: %v", r.URL.Path, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
io.WriteString(w, "500 An error has occurred")
|
||||
return true
|
||||
}
|
||||
|
||||
if cu != nil {
|
||||
if redir := r.FormValue(FormValueRedirect); len(redir) > 0 {
|
||||
http.Redirect(w, r, redir, http.StatusFound)
|
||||
} else {
|
||||
http.Redirect(w, r, ctx.AuthLoginOKPath, http.StatusFound)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
*/
|
||||
|
@ -1,7 +1,6 @@
|
||||
package authboss
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
@ -29,22 +28,24 @@ var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
// ErrTokenNotFound should be returned from UseToken when the record is not found.
|
||||
ErrTokenNotFound = errors.New("token not found")
|
||||
// ErrUserFound should be returned from Create when the primaryID of the record is found.
|
||||
// ErrUserFound should be returned from Create (see ConfirmUser) when the primaryID
|
||||
// of the record is found.
|
||||
ErrUserFound = errors.New("user found")
|
||||
)
|
||||
|
||||
// StoreLoader represents the data store that's capable of loading users
|
||||
// ServerStorer represents the data store that's capable of loading users
|
||||
// and giving them a context with which to store themselves.
|
||||
type StoreLoader interface {
|
||||
// Load will be passed the PrimaryID and return pre-loaded storer (meaning
|
||||
// Storer.Load will not be called)
|
||||
Load(ctx context.Context, key string) (Storer, error)
|
||||
type ServerStorer interface {
|
||||
// Load will look up the user based on the passed the PrimaryID
|
||||
Load(ctx context.Context, key string) (User, error)
|
||||
|
||||
// Save persists the user in the database
|
||||
Save(ctx context.Context, user User) error
|
||||
}
|
||||
|
||||
// Storer represents a user that also knows how to put himself into the db.
|
||||
// It has functions for each piece of data it requires.
|
||||
// Note that you should only persist data once Save() has been called.
|
||||
type Storer interface {
|
||||
// User has functions for each piece of data it requires.
|
||||
// Data should not be persisted on each function call.
|
||||
type User interface {
|
||||
PutEmail(ctx context.Context, email string) error
|
||||
PutUsername(ctx context.Context, username string) error
|
||||
PutPassword(ctx context.Context, password string) error
|
||||
@ -52,21 +53,13 @@ type Storer interface {
|
||||
GetEmail(ctx context.Context) (email string, err error)
|
||||
GetUsername(ctx context.Context) (username string, err error)
|
||||
GetPassword(ctx context.Context) (password string, err error)
|
||||
|
||||
// Save the state.
|
||||
Save(ctx context.Context) error
|
||||
|
||||
// Load the state based on the properties that have been given (typically
|
||||
// an e-mail/username).
|
||||
Load(ctx context.Context) error
|
||||
}
|
||||
|
||||
// TODO(aarondl): Document & move to Register module
|
||||
// ArbitraryStorer allows arbitrary data from the web form through. You should
|
||||
// ArbitraryUser allows arbitrary data from the web form through. You should
|
||||
// definitely only pull the keys you want from the map, since this is unfiltered
|
||||
// input from a web request and is an attack vector.
|
||||
type ArbitraryStorer interface {
|
||||
Storer
|
||||
type ArbitraryUser interface {
|
||||
User
|
||||
|
||||
// PutArbitrary allows arbitrary fields defined by the authboss library
|
||||
// consumer to add fields to the user registration piece.
|
||||
@ -76,10 +69,12 @@ type ArbitraryStorer interface {
|
||||
GetArbitrary(ctx context.Context) (arbitrary map[string]string, err error)
|
||||
}
|
||||
|
||||
// OAuth2Storer allows reading and writing values relating to OAuth2
|
||||
type OAuth2Storer interface {
|
||||
Storer
|
||||
// OAuth2User allows reading and writing values relating to OAuth2
|
||||
type OAuth2User interface {
|
||||
User
|
||||
|
||||
// IsOAuth2User checks to see if a user was registered in the site as an
|
||||
// oauth2 user.
|
||||
IsOAuth2User(ctx context.Context) (bool, error)
|
||||
|
||||
PutUID(ctx context.Context, uid string) error
|
||||
@ -94,36 +89,3 @@ type OAuth2Storer interface {
|
||||
GetRefreshToken(ctx context.Context) (refreshToken string, err error)
|
||||
GetExpiry(ctx context.Context) (expiry time.Duration, err error)
|
||||
}
|
||||
|
||||
func camelToUnder(in string) string {
|
||||
out := bytes.Buffer{}
|
||||
for i := 0; i < len(in); i++ {
|
||||
chr := in[i]
|
||||
if chr >= 'A' && chr <= 'Z' {
|
||||
if i > 0 {
|
||||
out.WriteByte('_')
|
||||
}
|
||||
out.WriteByte(chr + 'a' - 'A')
|
||||
} else {
|
||||
out.WriteByte(chr)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func underToCamel(in string) string {
|
||||
out := bytes.Buffer{}
|
||||
for i := 0; i < len(in); i++ {
|
||||
chr := in[i]
|
||||
|
||||
if first := i == 0; first || chr == '_' {
|
||||
if !first {
|
||||
i++
|
||||
}
|
||||
out.WriteByte(in[i] - 'a' + 'A')
|
||||
} else {
|
||||
out.WriteByte(chr)
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
package authboss
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCasingStyleConversions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
In string
|
||||
Out string
|
||||
}{
|
||||
{"SomethingInCamel", "something_in_camel"},
|
||||
{"Oauth2Anything", "oauth2_anything"},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
out := camelToUnder(test.In)
|
||||
if out != test.Out {
|
||||
t.Errorf("%d) Expected %q got %q", i, test.Out, out)
|
||||
}
|
||||
out = underToCamel(out)
|
||||
if out != test.In {
|
||||
t.Errorf("%d), Expected %q got %q", i, test.In, out)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user