You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
215 lines
7.4 KiB
215 lines
7.4 KiB
// Package web provides the login-gated HTTP surface for the application.
|
|
package web
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"time"
|
|
|
|
"webfilebrowser/internal/auth"
|
|
)
|
|
|
|
const (
|
|
loginPath = "/login"
|
|
logoutPath = "/logout"
|
|
changePasswordPath = "/change-password"
|
|
sessionCookieName = "webfilebrowser_session"
|
|
)
|
|
|
|
var (
|
|
loginTemplate = template.Must(template.New("login").Parse(`<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8"><title>Sign in</title></head>
|
|
<body><main><h1>Sign in</h1>{{if .Error}}<p role="alert">{{.Error}}</p>{{end}}
|
|
<form method="post" action="/login"><label>Email <input type="email" name="email" required></label>
|
|
<label>Password <input type="password" name="password" required></label><button type="submit">Sign in</button></form></main></body></html>`))
|
|
changePasswordTemplate = template.Must(template.New("change-password").Parse(`<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8"><title>Change password</title></head>
|
|
<body><main><h1>Change your password</h1><p>You must choose a new password before continuing.</p>{{if .Error}}<p role="alert">{{.Error}}</p>{{end}}
|
|
<form method="post" action="/change-password"><label>Current password <input type="password" name="current_password" required></label>
|
|
<label>New password <input type="password" name="new_password" required></label><button type="submit">Change password</button></form></main></body></html>`))
|
|
)
|
|
|
|
// New returns an HTTP handler that requires a valid server-side session for
|
|
// every route other than the login endpoint.
|
|
func New(store *auth.Store) http.Handler {
|
|
return &application{store: store}
|
|
}
|
|
|
|
type application struct {
|
|
store *auth.Store
|
|
}
|
|
|
|
func (app *application) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
|
if request.URL.Path == loginPath {
|
|
app.handleLogin(writer, request)
|
|
return
|
|
}
|
|
|
|
user, token, ok := app.currentUser(writer, request)
|
|
if !ok {
|
|
return
|
|
}
|
|
if request.URL.Path == logoutPath {
|
|
app.handleLogout(writer, request, token)
|
|
return
|
|
}
|
|
if user.MustChangePassword {
|
|
if request.URL.Path != changePasswordPath {
|
|
http.Redirect(writer, request, changePasswordPath, http.StatusSeeOther)
|
|
return
|
|
}
|
|
app.handleChangePassword(writer, request, user)
|
|
return
|
|
}
|
|
if request.URL.Path == changePasswordPath {
|
|
http.Redirect(writer, request, "/", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if request.URL.Path == "/" {
|
|
app.handleHome(writer, request, user)
|
|
return
|
|
}
|
|
|
|
http.NotFound(writer, request)
|
|
}
|
|
|
|
func (app *application) handleLogin(writer http.ResponseWriter, request *http.Request) {
|
|
switch request.Method {
|
|
case http.MethodGet:
|
|
renderLogin(writer, http.StatusOK, "")
|
|
case http.MethodPost:
|
|
if err := request.ParseForm(); err != nil {
|
|
renderLogin(writer, http.StatusBadRequest, "Unable to read sign-in details.")
|
|
return
|
|
}
|
|
user, err := app.store.Authenticate(request.FormValue("email"), request.FormValue("password"))
|
|
if err != nil {
|
|
renderLogin(writer, http.StatusUnauthorized, "Invalid email or password.")
|
|
return
|
|
}
|
|
token, err := app.store.CreateSession(user.ID)
|
|
if err != nil {
|
|
http.Error(writer, "Unable to create session.", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
app.setSessionCookie(writer, token)
|
|
destination := "/"
|
|
if user.MustChangePassword {
|
|
destination = changePasswordPath
|
|
}
|
|
http.Redirect(writer, request, destination, http.StatusSeeOther)
|
|
default:
|
|
writer.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
|
|
http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (app *application) handleChangePassword(writer http.ResponseWriter, request *http.Request, user auth.User) {
|
|
switch request.Method {
|
|
case http.MethodGet:
|
|
renderChangePassword(writer, http.StatusOK, "")
|
|
case http.MethodPost:
|
|
if err := request.ParseForm(); err != nil {
|
|
renderChangePassword(writer, http.StatusBadRequest, "Unable to read password details.")
|
|
return
|
|
}
|
|
err := app.store.ChangePassword(user.ID, request.FormValue("current_password"), request.FormValue("new_password"))
|
|
if errors.Is(err, auth.ErrPasswordUnchanged) {
|
|
renderChangePassword(writer, http.StatusBadRequest, "Your new password must differ from the current password.")
|
|
return
|
|
}
|
|
if errors.Is(err, auth.ErrInvalidCredentials) {
|
|
renderChangePassword(writer, http.StatusBadRequest, "Your current password is incorrect.")
|
|
return
|
|
}
|
|
if err != nil {
|
|
http.Error(writer, "Unable to change password.", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(writer, request, "/", http.StatusSeeOther)
|
|
default:
|
|
writer.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
|
|
http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (app *application) handleLogout(writer http.ResponseWriter, request *http.Request, token string) {
|
|
if request.Method != http.MethodPost {
|
|
writer.Header().Set("Allow", http.MethodPost)
|
|
http.Error(writer, "Method not allowed.", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if err := app.store.DeleteSession(token); err != nil {
|
|
http.Error(writer, "Unable to end session.", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.SetCookie(writer, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
http.Redirect(writer, request, loginPath, http.StatusSeeOther)
|
|
}
|
|
|
|
func (app *application) handleHome(writer http.ResponseWriter, _ *http.Request, user auth.User) {
|
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = fmt.Fprintf(writer, "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>webFileBrowser</title></head><body><main><h1>webFileBrowser</h1><p>File browser is being prepared.</p><p>Signed in as %s.</p><form method=\"post\" action=\"/logout\"><button type=\"submit\">Sign out</button></form></main></body></html>", template.HTMLEscapeString(user.Email))
|
|
}
|
|
|
|
func (app *application) currentUser(writer http.ResponseWriter, request *http.Request) (auth.User, string, bool) {
|
|
cookie, err := request.Cookie(sessionCookieName)
|
|
if err != nil {
|
|
http.Redirect(writer, request, loginPath, http.StatusSeeOther)
|
|
return auth.User{}, "", false
|
|
}
|
|
user, err := app.store.LookupSession(cookie.Value)
|
|
if err != nil {
|
|
app.clearSessionCookie(writer)
|
|
http.Redirect(writer, request, loginPath, http.StatusSeeOther)
|
|
return auth.User{}, "", false
|
|
}
|
|
app.setSessionCookie(writer, cookie.Value)
|
|
return user, cookie.Value, true
|
|
}
|
|
|
|
func (app *application) setSessionCookie(writer http.ResponseWriter, token string) {
|
|
http.SetCookie(writer, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
Expires: time.Now().Add(app.store.SessionLifetime()),
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func (app *application) clearSessionCookie(writer http.ResponseWriter) {
|
|
http.SetCookie(writer, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func renderLogin(writer http.ResponseWriter, status int, message string) {
|
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
writer.WriteHeader(status)
|
|
_ = loginTemplate.Execute(writer, struct{ Error string }{Error: message})
|
|
}
|
|
|
|
func renderChangePassword(writer http.ResponseWriter, status int, message string) {
|
|
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
writer.WriteHeader(status)
|
|
_ = changePasswordTemplate.Execute(writer, struct{ Error string }{Error: message})
|
|
}
|
|
|