@@ -8,6 +8,7 @@ import ( | |||
"syscall" | |||
"git.sr.ht/~emersion/alps" | |||
"github.com/fernet/fernet-go" | |||
"github.com/labstack/echo/v4" | |||
"github.com/labstack/echo/v4/middleware" | |||
"github.com/labstack/gommon/log" | |||
@@ -21,11 +22,15 @@ import ( | |||
) | |||
func main() { | |||
var options alps.Options | |||
var addr string | |||
var ( | |||
addr string | |||
loginKey string | |||
options alps.Options | |||
) | |||
flag.StringVar(&options.Theme, "theme", "", "default theme") | |||
flag.StringVar(&addr, "addr", ":1323", "listening address") | |||
flag.BoolVar(&options.Debug, "debug", false, "enable debug logs") | |||
flag.StringVar(&loginKey, "login-key", "", "Fernet key for login persistence") | |||
flag.Usage = func() { | |||
fmt.Fprintf(flag.CommandLine.Output(), "usage: alps [options...] <upstream servers...>\n") | |||
@@ -40,6 +45,15 @@ func main() { | |||
return | |||
} | |||
if loginKey != "" { | |||
fernetKey, err := fernet.DecodeKey(loginKey) | |||
if err != nil { | |||
flag.Usage() | |||
return | |||
} | |||
options.LoginKey = fernetKey | |||
} | |||
e := echo.New() | |||
e.HideBanner = true | |||
if l, ok := e.Logger.(*log.Logger); ok { | |||
@@ -15,6 +15,7 @@ require ( | |||
github.com/emersion/go-smtp v0.13.0 | |||
github.com/emersion/go-vcard v0.0.0-20200508080525-dd3110a24ec2 | |||
github.com/emersion/go-webdav v0.3.1-0.20200513144525-a4e0e8100397 | |||
github.com/fernet/fernet-go v0.0.0-20191111064656-eff2850e6001 | |||
github.com/google/uuid v1.1.1 | |||
github.com/gorilla/css v1.0.0 // indirect | |||
github.com/labstack/echo/v4 v4.1.16 | |||
@@ -42,6 +42,8 @@ github.com/emersion/go-vcard v0.0.0-20200508080525-dd3110a24ec2 h1:g1RgqggIPPkEB | |||
github.com/emersion/go-vcard v0.0.0-20200508080525-dd3110a24ec2/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= | |||
github.com/emersion/go-webdav v0.3.1-0.20200513144525-a4e0e8100397 h1:XVnGMemAywvBnsUAIsx4v+avxzauS00Mf9l9oM9olFc= | |||
github.com/emersion/go-webdav v0.3.1-0.20200513144525-a4e0e8100397/go.mod h1:uSM1VveeKtogBVWaYccTksToczooJ0rrVGNsgnDsr4Q= | |||
github.com/fernet/fernet-go v0.0.0-20191111064656-eff2850e6001 h1:/UMxx5lGDg30aioUL9e7xJnbJfJeX7vhcm57fa5udaI= | |||
github.com/fernet/fernet-go v0.0.0-20191111064656-eff2850e6001/go.mod h1:2H9hjfbpSMHwY503FclkV/lZTBh2YlOmLLSda12uL8c= | |||
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= | |||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | |||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= | |||
@@ -160,6 +160,12 @@ func handleGetMailbox(ctx *alps.Context) error { | |||
func handleLogin(ctx *alps.Context) error { | |||
username := ctx.FormValue("username") | |||
password := ctx.FormValue("password") | |||
remember := ctx.FormValue("remember-me") | |||
if username == "" && password == "" { | |||
username, password = ctx.GetLoginToken() | |||
} | |||
if username != "" && password != "" { | |||
s, err := ctx.Server.Sessions.Put(username, password) | |||
if err != nil { | |||
@@ -170,18 +176,30 @@ func handleLogin(ctx *alps.Context) error { | |||
} | |||
ctx.SetSession(s) | |||
if remember == "on" { | |||
ctx.SetLoginToken(username, password) | |||
} | |||
if path := ctx.QueryParam("next"); path != "" && path[0] == '/' && path != "/login" { | |||
return ctx.Redirect(http.StatusFound, path) | |||
} | |||
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") | |||
} | |||
return ctx.Render(http.StatusOK, "login.html", alps.NewBaseRenderData(ctx)) | |||
return ctx.Render(http.StatusOK, "login.html", | |||
&struct { | |||
alps.BaseRenderData | |||
CanRememberMe bool | |||
}{ | |||
BaseRenderData: *alps.NewBaseRenderData(ctx), | |||
CanRememberMe: ctx.Server.Options.LoginKey != nil, | |||
}) | |||
} | |||
func handleLogout(ctx *alps.Context) error { | |||
ctx.Session.Close() | |||
ctx.SetSession(nil) | |||
ctx.SetLoginToken("", "") | |||
return ctx.Redirect(http.StatusFound, "/login") | |||
} | |||
@@ -1,7 +1,9 @@ | |||
package alps | |||
import ( | |||
"encoding/json" | |||
"fmt" | |||
"log" | |||
"net/http" | |||
"net/url" | |||
"strings" | |||
@@ -9,14 +11,19 @@ import ( | |||
"time" | |||
"github.com/labstack/echo/v4" | |||
"github.com/fernet/fernet-go" | |||
) | |||
const cookieName = "alps_session" | |||
const ( | |||
cookieName = "alps_session" | |||
loginTokenCookieName = "alps_login_token" | |||
) | |||
// Server holds all the alps server state. | |||
type Server struct { | |||
e *echo.Echo | |||
Sessions *SessionManager | |||
Options *Options | |||
mutex sync.RWMutex // used for server reload | |||
plugins []Plugin | |||
@@ -34,11 +41,10 @@ type Server struct { | |||
tls bool | |||
insecure bool | |||
} | |||
defaultTheme string | |||
} | |||
func newServer(e *echo.Echo, options *Options) (*Server, error) { | |||
s := &Server{e: e, defaultTheme: options.Theme} | |||
s := &Server{e: e, Options: options} | |||
s.upstreams = make(map[string]*url.URL, len(options.Upstreams)) | |||
for _, upstream := range options.Upstreams { | |||
@@ -195,7 +201,7 @@ func (s *Server) load() error { | |||
plugins = append(plugins, l...) | |||
} | |||
renderer := newRenderer(s.e.Logger, s.defaultTheme) | |||
renderer := newRenderer(s.e.Logger, s.Options.Theme) | |||
if err := renderer.Load(plugins); err != nil { | |||
return fmt.Errorf("failed to load templates: %v", err) | |||
} | |||
@@ -262,6 +268,70 @@ func (ctx *Context) SetSession(s *Session) { | |||
ctx.SetCookie(&cookie) | |||
} | |||
type loginToken struct { | |||
Username string | |||
Password string | |||
} | |||
func (ctx *Context) SetLoginToken(username, password string) { | |||
cookie := http.Cookie{ | |||
Expires: time.Now().Add(30 * 24 * time.Hour), | |||
Name: loginTokenCookieName, | |||
HttpOnly: true, | |||
Path: "/login", | |||
} | |||
if username == "" { | |||
cookie.Expires = aLongTimeAgo // unset the cookie | |||
ctx.SetCookie(&cookie) | |||
return | |||
} | |||
loginToken := loginToken{username, password} | |||
payload, err := json.Marshal(loginToken) | |||
if err != nil { | |||
panic(err) // Should never happen | |||
} | |||
fkey := ctx.Server.Options.LoginKey | |||
if fkey == nil { | |||
return | |||
} | |||
bytes, err := fernet.EncryptAndSign(payload, fkey) | |||
if err != nil { | |||
log.Printf("Warning: login token encryption failed: %v", err) | |||
return | |||
} | |||
cookie.Value = string(bytes) | |||
ctx.SetCookie(&cookie) | |||
} | |||
func (ctx *Context) GetLoginToken() (string, string) { | |||
cookie, err := ctx.Cookie(loginTokenCookieName) | |||
if err != nil || cookie == nil { | |||
return "", "" | |||
} | |||
fkey := ctx.Server.Options.LoginKey | |||
if fkey == nil { | |||
return "", "" | |||
} | |||
bytes := fernet.VerifyAndDecrypt([]byte(cookie.Value), | |||
24 * time.Hour * 30, []*fernet.Key{fkey}) | |||
if bytes == nil { | |||
return "", "" | |||
} | |||
var token loginToken | |||
err = json.Unmarshal(bytes, &token) | |||
if err != nil { | |||
panic(err) // Should never happen | |||
} | |||
return token.Username, token.Password | |||
} | |||
func isPublic(path string) bool { | |||
if strings.HasPrefix(path, "/plugins/") { | |||
parts := strings.Split(path, "/") | |||
@@ -292,6 +362,7 @@ type Options struct { | |||
Upstreams []string | |||
Theme string | |||
Debug bool | |||
LoginKey *fernet.Key | |||
} | |||
// New creates a new server. | |||
@@ -398,6 +398,12 @@ main table tfoot { | |||
width: 100%; | |||
} | |||
.action-group .checkbox input { | |||
display: inline; | |||
width: 1rem; | |||
float: left; | |||
} | |||
.actions-message, | |||
.actions-contacts { | |||
display: flex; | |||
@@ -6,19 +6,24 @@ | |||
<form method="post" action="/login"> | |||
<div class="action-group"> | |||
<label for="username"> | |||
<strong>Username</strong> | |||
</label> | |||
<label for="username">Username</label> | |||
<input type="text" name="username" id="username" autofocus /> | |||
</div> | |||
<div class="action-group"> | |||
<label for="password"> | |||
<strong>Password</strong> | |||
</label> | |||
<label for="password">Password</label> | |||
<input type="password" name="password" id="password" /> | |||
</div> | |||
{{if .CanRememberMe}} | |||
<div class="action-group"> | |||
<label for="remember-me" class="checkbox"> | |||
<input type="checkbox" name="remember-me" id="remember-me" /> | |||
Remember me | |||
</label> | |||
</div> | |||
{{end}} | |||
<div class="action-group"> | |||
<button type="submit">Sign in</button> | |||
</div> | |||