diff --git a/cmd/alps/main.go b/cmd/alps/main.go index c0b77f2..4c358d5 100644 --- a/cmd/alps/main.go +++ b/cmd/alps/main.go @@ -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...] \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 { diff --git a/go.mod b/go.mod index 6d761e8..e0f022b 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 8a191bb..a0b4496 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/plugins/base/routes.go b/plugins/base/routes.go index 3b47668..d023b4b 100644 --- a/plugins/base/routes.go +++ b/plugins/base/routes.go @@ -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") } diff --git a/server.go b/server.go index 250e932..f6ef5ae 100644 --- a/server.go +++ b/server.go @@ -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. diff --git a/themes/alps/assets/style.css b/themes/alps/assets/style.css index 2c8ce16..5225b9d 100644 --- a/themes/alps/assets/style.css +++ b/themes/alps/assets/style.css @@ -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; diff --git a/themes/alps/login.html b/themes/alps/login.html index e642050..6f7c372 100644 --- a/themes/alps/login.html +++ b/themes/alps/login.html @@ -6,19 +6,24 @@
- +
- +
+ {{if .CanRememberMe}} +
+ +
+ {{end}} +