22 changed files with 702 additions and 134 deletions
@ -0,0 +1,87 @@ |
|||
package authentication |
|||
|
|||
import ( |
|||
"context" |
|||
"errors" |
|||
"log" |
|||
"net/http" |
|||
|
|||
"git.kirsle.net/apps/gophertype/pkg/models" |
|||
"git.kirsle.net/apps/gophertype/pkg/session" |
|||
) |
|||
|
|||
// CurrentUser returns the currently logged-in user in the browser session.
|
|||
func CurrentUser(r *http.Request) (models.User, error) { |
|||
sess := session.Get(r) |
|||
if loggedIn, ok := sess.Values["logged-in"].(bool); ok && loggedIn { |
|||
id := sess.Values["user-id"].(int) |
|||
user, err := models.GetUserByID(id) |
|||
return user, err |
|||
} |
|||
return models.User{}, errors.New("not logged in") |
|||
} |
|||
|
|||
// Login logs the browser session in as the user.
|
|||
func Login(w http.ResponseWriter, r *http.Request, user models.User) { |
|||
sess := session.Get(r) |
|||
|
|||
sess.Values["logged-in"] = true |
|||
sess.Values["user-id"] = int(user.ID) |
|||
if err := sess.Save(r, w); err != nil { |
|||
log.Printf("ERROR: Login() Session error: " + err.Error()) |
|||
} |
|||
} |
|||
|
|||
// Logout logs the current user out.
|
|||
func Logout(w http.ResponseWriter, r *http.Request) { |
|||
sess := session.Get(r) |
|||
sess.Values["logged-in"] = false |
|||
sess.Values["user-id"] = 0 |
|||
sess.Save(r, w) |
|||
} |
|||
|
|||
// LoggedIn returns whether the session is logged in as a user.
|
|||
func LoggedIn(r *http.Request) bool { |
|||
sess := session.Get(r) |
|||
if v, ok := sess.Values["logged-in"].(bool); ok && v == true { |
|||
return true |
|||
} |
|||
return false |
|||
} |
|||
|
|||
// LoginRequired is a middleware for authenticated endpoints.
|
|||
func LoginRequired(next http.Handler) http.Handler { |
|||
middleware := func(w http.ResponseWriter, r *http.Request) { |
|||
ctx := r.Context() |
|||
if user, ok := ctx.Value(session.UserKey).(models.User); ok { |
|||
if user.ID > 0 { |
|||
next.ServeHTTP(w, r) |
|||
return |
|||
} |
|||
} |
|||
|
|||
// Redirect to the login page.
|
|||
w.Header().Set("Location", "/login?next="+r.URL.Path) |
|||
w.WriteHeader(http.StatusFound) |
|||
} |
|||
|
|||
return http.HandlerFunc(middleware) |
|||
} |
|||
|
|||
// Middleware checks the authentication and loads the user onto the request context.
|
|||
func Middleware(next http.Handler) http.Handler { |
|||
middleware := func(w http.ResponseWriter, r *http.Request) { |
|||
user, err := CurrentUser(r) |
|||
if err != nil { |
|||
// User not logged in, go to next middleware.
|
|||
next.ServeHTTP(w, r) |
|||
return |
|||
} |
|||
|
|||
// Put the CurrentUser into the request context.
|
|||
ctx := context.WithValue(r.Context(), session.UserKey, user) |
|||
next.ServeHTTP(w, r.WithContext(ctx)) |
|||
} |
|||
|
|||
return http.HandlerFunc(middleware) |
|||
} |
@ -0,0 +1,66 @@ |
|||
package controllers |
|||
|
|||
import ( |
|||
"log" |
|||
"net/http" |
|||
|
|||
"git.kirsle.net/apps/gophertype/pkg/authentication" |
|||
"git.kirsle.net/apps/gophertype/pkg/glue" |
|||
"git.kirsle.net/apps/gophertype/pkg/models" |
|||
"git.kirsle.net/apps/gophertype/pkg/responses" |
|||
"git.kirsle.net/apps/gophertype/pkg/session" |
|||
"github.com/albrow/forms" |
|||
) |
|||
|
|||
func init() { |
|||
glue.Register(glue.Endpoint{ |
|||
Path: "/login", |
|||
Methods: []string{"GET", "POST"}, |
|||
Handler: func(w http.ResponseWriter, r *http.Request) { |
|||
// Template variables.
|
|||
v := responses.NewTemplateVars(w, r) |
|||
|
|||
// POST handler: create the admin account.
|
|||
for r.Method == http.MethodPost { |
|||
form, _ := forms.Parse(r) |
|||
v.FormValues = form.Values |
|||
|
|||
// Validate form parameters.
|
|||
val := form.Validator() |
|||
val.Require("email") |
|||
val.MatchEmail("email") |
|||
val.Require("password") |
|||
if val.HasErrors() { |
|||
v.ValidationError = val.ErrorMap() |
|||
log.Printf("validation: %+v", v.ValidationError) |
|||
break |
|||
} |
|||
|
|||
// Check authentication.
|
|||
user, err := models.AuthenticateUser(form.Get("email"), form.Get("password")) |
|||
if err != nil { |
|||
v.Error = err |
|||
break |
|||
} |
|||
|
|||
_ = user |
|||
|
|||
authentication.Login(w, r, user) |
|||
session.Flash(w, r, "Signed in!") |
|||
responses.Redirect(w, r, "/") // TODO: next URL
|
|||
return |
|||
} |
|||
|
|||
responses.RenderTemplate(w, r, "_builtin/users/login.gohtml", v) |
|||
}, |
|||
}) |
|||
|
|||
glue.Register(glue.Endpoint{ |
|||
Path: "/logout", |
|||
Handler: func(w http.ResponseWriter, r *http.Request) { |
|||
authentication.Logout(w, r) |
|||
session.Flash(w, r, "Signed out!") |
|||
responses.Redirect(w, r, "/") |
|||
}, |
|||
}) |
|||
} |
@ -0,0 +1,82 @@ |
|||
package responses |
|||
|
|||
import ( |
|||
"errors" |
|||
"io/ioutil" |
|||
"os" |
|||
"strings" |
|||
|
|||
"git.kirsle.net/apps/gophertype/pkg/bundled" |
|||
) |
|||
|
|||
// GetFile returns the template file's data, wherever it is.
|
|||
// Checks the embedded bindata, then the user root on disk, then error.
|
|||
// If it can be found, returns the contents or error.
|
|||
func GetFile(path string) ([]byte, error) { |
|||
// Check bindata.
|
|||
if b, err := bundled.Asset(path); err == nil { |
|||
return b, nil |
|||
} |
|||
|
|||
// Check the filesystem. TODO
|
|||
if b, err := ioutil.ReadFile("./pvt-www/" + path); err == nil { |
|||
return b, nil |
|||
} else { |
|||
return []byte{}, err |
|||
} |
|||
} |
|||
|
|||
// GetFileExists checks if the file exists but doesn't return its data.
|
|||
func GetFileExists(path string) bool { |
|||
// Check bindata.
|
|||
if _, err := bundled.AssetInfo(path); err == nil { |
|||
return true |
|||
} |
|||
|
|||
// Check the filesystem. TODO
|
|||
if _, err := os.Stat(path); err == nil { |
|||
return true |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
/* |
|||
ResolveFile searches for the existence of a file from a fuzzy URL path. |
|||
|
|||
`path` is a request path like "/about" |
|||
|
|||
This function would return e.g. "about.gohtml" as being a file path that is |
|||
sure to return data in GetFile(). |
|||
|
|||
Path finding rules follow expected behavior from dominant web servers: |
|||
|
|||
- If the exact path is found, return it immediately. |
|||
- Try assuming a ".gohtml" or ".md" file extension for the path. |
|||
- Try checking if the path is a directory with an "index.gohtml" inside it, etc. |
|||
*/ |
|||
func ResolveFile(path string) (string, error) { |
|||
// Ensure the path doesn't begin with a slash.
|
|||
path = strings.TrimLeft(path, "/") |
|||
|
|||
// Try the exact path.
|
|||
if GetFileExists(path) { |
|||
return path, nil |
|||
} |
|||
|
|||
// Try fuzzy file matches.
|
|||
var tries = []string{ |
|||
path + ".gohtml", |
|||
path + ".md", |
|||
path + "/index.gohtml", |
|||
path + "/index.html", |
|||
} |
|||
for _, try := range tries { |
|||
path = strings.TrimLeft(try, "/") |
|||
if GetFileExists(path) { |
|||
return path, nil |
|||
} |
|||
} |
|||
|
|||
return "", errors.New("not found") |
|||
} |
@ -0,0 +1,9 @@ |
|||
package responses |
|||
|
|||
import "net/http" |
|||
|
|||
// Redirect to a different URL.
|
|||
func Redirect(w http.ResponseWriter, r *http.Request, url string) { |
|||
w.Header().Set("Location", url) |
|||
w.WriteHeader(http.StatusFound) |
|||
} |
@ -0,0 +1,77 @@ |
|||
package responses |
|||
|
|||
import ( |
|||
"fmt" |
|||
"net/http" |
|||
"net/url" |
|||
"time" |
|||
|
|||
"git.kirsle.net/apps/gophertype/pkg/authentication" |
|||
"git.kirsle.net/apps/gophertype/pkg/models" |
|||
"git.kirsle.net/apps/gophertype/pkg/session" |
|||
"git.kirsle.net/apps/gophertype/pkg/settings" |
|||
"github.com/gorilla/sessions" |
|||
) |
|||
|
|||
// NewTemplateVars creates the TemplateVars for your current request.
|
|||
func NewTemplateVars(w http.ResponseWriter, r *http.Request) TemplateValues { |
|||
var s = settings.Current |
|||
user, _ := authentication.CurrentUser(r) |
|||
|
|||
v := TemplateValues{ |
|||
SetupNeeded: !s.Initialized, |
|||
|
|||
Title: s.Title, |
|||
Description: s.Description, |
|||
|
|||
Request: r, |
|||
RequestTime: time.Now(), |
|||
RequestDuration: time.Duration(0), |
|||
Path: r.URL.Path, |
|||
|
|||
LoggedIn: authentication.LoggedIn(r), |
|||
IsAdmin: user.IsAdmin, |
|||
CurrentUser: user, |
|||
|
|||
Flashes: session.GetFlashes(w, r), |
|||
} |
|||
return v |
|||
} |
|||
|
|||
// TemplateValues holds the context for html templates.
|
|||
type TemplateValues struct { |
|||
// When the site needs the initial config.
|
|||
SetupNeeded bool |
|||
|
|||
// Config values available as template variables.
|
|||
Title string |
|||
Description string |
|||
|
|||
// Request variables
|
|||
Request *http.Request |
|||
RequestTime time.Time |
|||
RequestDuration time.Duration |
|||
FormValues url.Values |
|||
Path string // request path
|
|||
TemplatePath string // file path of html template, like "_builtin/error.gohtml"
|
|||
|
|||
// Session variables
|
|||
Session *sessions.Session |
|||
LoggedIn bool |
|||
IsAdmin bool |
|||
CurrentUser models.User |
|||
|
|||
// Common template variables.
|
|||
Message string |
|||
Error interface{} |
|||
ValidationError map[string][]string // form validation errors
|
|||
Flashes []string |
|||
|
|||
// Arbitrary controller-specific fields go in V.
|
|||
V interface{} |
|||
} |
|||
|
|||
// Flash adds a message to flash on the next template render.
|
|||
func (v *TemplateValues) Flash(msg string, args ...interface{}) { |
|||
v.Flashes = append(v.Flashes, fmt.Sprintf(msg, args...)) |
|||
} |
@ -0,0 +1,11 @@ |
|||
package session |
|||
|
|||
// Key is a session context key.
|
|||
type Key int |
|||
|
|||
// Session key definitions.
|
|||
const ( |
|||
SessionKey Key = iota // The request's cookie session object.
|
|||
UserKey // The request's user data for logged-in user.
|
|||
StartTimeKey // The start time of the request.
|
|||
) |
@ -0,0 +1,86 @@ |
|||
package session |
|||
|
|||
import ( |
|||
"context" |
|||
"fmt" |
|||
"net/http" |
|||
"time" |
|||
|
|||
"github.com/gorilla/sessions" |
|||
"github.com/kirsle/blog/src/types" |
|||
) |
|||
|
|||
// Store holds your cookie store information.
|
|||
var Store sessions.Store |
|||
|
|||
// SetSecretKey initializes a session cookie store with the secret key.
|
|||
func SetSecretKey(keyPairs ...[]byte) { |
|||
fmt.Printf("XXXXX SetSecretKey: %+v", keyPairs) |
|||
Store = sessions.NewCookieStore(keyPairs...) |
|||
} |
|||
|
|||
// Middleware gets the Gorilla session store and makes it available on the
|
|||
// Request context.
|
|||
//
|
|||
// Middleware is the first custom middleware applied, so it takes the current
|
|||
// datetime to make available later in the request and stores it on the request
|
|||
// context.
|
|||
func Middleware(next http.Handler) http.Handler { |
|||
middleware := func(w http.ResponseWriter, r *http.Request) { |
|||
// Store the current datetime on the request context.
|
|||
ctx := context.WithValue(r.Context(), StartTimeKey, time.Now()) |
|||
|
|||
// Get the Gorilla session and make it available in the request context.
|
|||
session, _ := Store.Get(r, "session") |
|||
ctx = context.WithValue(ctx, SessionKey, session) |
|||
|
|||
next.ServeHTTP(w, r.WithContext(ctx)) |
|||
} |
|||
|
|||
return http.HandlerFunc(middleware) |
|||
} |
|||
|
|||
// Get returns the current request's session.
|
|||
func Get(r *http.Request) *sessions.Session { |
|||
if r == nil { |
|||
panic("Session(*http.Request) with a nil argument!?") |
|||
} |
|||
|
|||
ctx := r.Context() |
|||
if session, ok := ctx.Value(types.SessionKey).(*sessions.Session); ok { |
|||
return session |
|||
} |
|||
|
|||
// If the session wasn't on the request, it means I broke something.
|
|||
fmt.Printf( |
|||
"ERROR: Session(): didn't find session in request context! Getting it " + |
|||
"from the session store instead.", |
|||
) |
|||
session, _ := Store.Get(r, "session") |
|||
return session |
|||
} |
|||
|
|||
// Flash adds a flashed message to the session for the next template rendering.
|
|||
func Flash(w http.ResponseWriter, r *http.Request, msg string, args ...interface{}) { |
|||
sess := Get(r) |
|||
|
|||
var flashes []string |
|||
if v, ok := sess.Values["flashes"].([]string); ok { |
|||
flashes = v |
|||
} |
|||
|
|||
flashes = append(flashes, fmt.Sprintf(msg, args...)) |
|||
sess.Values["flashes"] = flashes |
|||
sess.Save(r, w) |
|||
} |
|||
|
|||
// GetFlashes returns all the flashes from the session and clears the queue.
|
|||
func GetFlashes(w http.ResponseWriter, r *http.Request) []string { |
|||
sess := Get(r) |
|||
if flashes, ok := sess.Values["flashes"].([]string); ok { |
|||
sess.Values["flashes"] = []string{} |
|||
sess.Save(r, w) |
|||
return flashes |
|||
} |
|||
return []string{} |
|||
} |
@ -0,0 +1,69 @@ |
|||
package settings |
|||
|
|||
import ( |
|||
"crypto/rand" |
|||
"encoding/base64" |
|||
|
|||
"git.kirsle.net/apps/gophertype/pkg/session" |
|||
) |
|||
|
|||
// Current holds the current app settings. When the app settings have never
|
|||
// been initialized, this struct holds the default values with a random secret
|
|||
// key. The config is not saved to DB until you call Save() on it.
|
|||
var Current = Load() |
|||
|
|||
// Spec singleton holds the app configuration.
|
|||
type Spec struct { |
|||
// Sets to `true` when the site's initial setup has run and an admin created.
|
|||
Initialized bool |
|||
|
|||
// Site information
|
|||
Title string |
|||
Description string |
|||
Email string // primary email for notifications
|
|||
NSFW bool |
|||
BaseURL string |
|||
|
|||
// Blog settings
|
|||
PostsPerPage int |
|||
|
|||
// Mail settings
|
|||
MailEnabled bool |
|||
MailSender string |
|||
MailHost string |
|||
MailPort int |
|||
MailUsername string |
|||
MailPassword string |
|||
|
|||
// Security
|
|||
SecretKey string `json:"-"` |
|||
} |
|||
|
|||
// Load gets or creates the App Settings.
|
|||
func Load() Spec { |
|||
var s = Spec{ |
|||
Title: "Untitled Site", |
|||
Description: "Just another web blog.", |
|||
SecretKey: MakeSecretKey(), |
|||
} |
|||
|
|||
session.SetSecretKey([]byte(s.SecretKey)) |
|||
|
|||
return s |
|||
} |
|||
|
|||
// Save the settings to DB.
|
|||
func (s Spec) Save() error { |
|||
Current = s |
|||
session.SetSecretKey([]byte(s.SecretKey)) |
|||
return nil |
|||
} |
|||
|
|||
// MakeSecretKey generates a secret key for signing HTTP cookies.
|
|||
func MakeSecretKey() string { |
|||
keyLength := 32 |
|||
|
|||
b := make([]byte, keyLength) |
|||
rand.Read(b) |
|||
return base64.URLEncoding.EncodeToString(b) |
|||
} |
@ -0,0 +1,9 @@ |
|||
{{ define "title" }}Error{{ end }} |
|||
{{ define "content" }} |
|||
<h1>An error has occurred.</h1> |
|||
|
|||
{{ if .Message }} |
|||
<p>{{ .Message }}</p> |
|||
{{ end }} |
|||
|
|||
{{ end }} |
@ -0,0 +1,34 @@ |
|||
{{ define "title" }}Sign in{{ end }} |
|||
{{ define "content" }} |
|||
<h1>Sign in</h1> |
|||
|
|||
<form method="POST" action="/login"> |
|||
{{ CSRF }} |
|||
<div class="form-row"> |
|||
<div class="form-group col-md-6"> |
|||
<label for="email">Email<span class="text-danger">*</span>:</label> |
|||
<input type="email" class="form-control" |
|||
name="email" |
|||
id="email" |
|||
value="{{ FormValue "email" }}" |
|||
placeholder="name@domain.com" |
|||
required> |
|||
</div> |
|||
<div class="form-group col-md-6"> |
|||
<label for="password">Password:</label> |
|||
<input type="password" class="form-control" |
|||
name="password" |
|||
id="password"> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-group row"> |
|||
<div class="form-group col"> |
|||
<p> |
|||
<button type="submit" class="btn btn-primary">Sign in</button> |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
|
|||
{{ end }} |
Loading…
Reference in new issue