Commit 61e5a7b2 authored by shammash's avatar shammash

Merge branch 'last_login' into 'master'

Last login

See merge request !1
parents 45a125c8 63bf61a3
Pipeline #2204 passed with stages
in 2 minutes and 13 seconds
......@@ -26,6 +26,11 @@ a specific device and an account if one is in possession of the
server-side log database (only partially mitigated by the fact that
the cookie is encrypted).
`usermetadb` also stores last-login information for internal infrastructure
maintenance. The idea is to retain the minimal amount of information to perform
tasks such as "disable accounts that have not been active for more than N
years".
# API
The server exports an API over HTTP/HTTPS, all requests should be made
......@@ -39,14 +44,14 @@ and the *analysis* API.
## Log API
`/api/add_log` (*AddLogRequest*)
Stores a new log entry for a user in the database. The request must be
a `LogEntry` object. The method returns an empty response. If the log
entry contains device information, the list of devices for the
specified user is updated with that information.
`/api/get_user_logs` (*GetUserLogsRequest*) -> *GetUserLogsResponse*
Returns recent logs for a specific user.
`/api/get_user_devices` (*GetUserDevicesRequest*) -> *GetUserDevicesResponse*
......@@ -60,6 +65,20 @@ Returns the list of known devices for a user.
Returns information about a device, whether we have seen it before, if
the localization information matches the historical trend, etc.
## Last-login API
`/api/set_last_login` (*SetLastLoginRequest*)
Stores the last login of a user in the database. The request must be a
`LastLoginEntry` object. The method returns an empty response. The service name
must be specified in the last login entry.
`/api/get_last_login` (*GetLastLoginRequest*) -> *GetLastLoginResponse*
Returns the last login of a given user. If the service name is specified it
returns the last login for that specific service, otherwise return last login
for all services.
# Configuration
......
......@@ -15,6 +15,8 @@ type Client interface {
AddLog(context.Context, string, *usermetadb.LogEntry) error
GetUserDevices(context.Context, string, string) ([]*usermetadb.MetaDeviceInfo, error)
GetUserLogs(context.Context, string, string, int, int) ([]*usermetadb.LogEntry, error)
SetLastLogin(context.Context, string, *usermetadb.LastLoginEntry) error
GetLastLogin(context.Context, string, string, string) ([]*usermetadb.LastLoginEntry, error)
}
type udbClient struct {
......@@ -63,3 +65,19 @@ func (c *udbClient) GetUserLogs(ctx context.Context, shard, username string, max
err := c.be.Call(ctx, shard, "/api/get_user_logs", &req, &resp)
return resp.Results, err
}
func (c *udbClient) SetLastLogin(ctx context.Context, shard string, entry *usermetadb.LastLoginEntry) error {
req := usermetadb.SetLastLoginRequest{LastLogin: entry}
return c.be.Call(ctx, shard, "/api/set_last_login", &req, nil)
}
func (c *udbClient) GetLastLogin(ctx context.Context, shard string, username string, service string) ([]*usermetadb.LastLoginEntry, error) {
req := usermetadb.GetLastLoginRequest{
Username: username,
Service: service,
}
var resp usermetadb.GetLastLoginResponse
err := c.be.Call(ctx, shard, "/api/get_last_login", &req, &resp)
return resp.Results, err
}
DROP INDEX idx_lastlogin_username_service;
DROP INDEX idx_lastlogin_username;
DROP TABLE lastlogin;
CREATE TABLE lastlogin (
username TEXT NOT NULL,
service TEXT NOT NULL,
timestamp DATETIME NOT NULL
);
CREATE UNIQUE INDEX idx_lastlogin_username_service ON lastlogin (username, service);
CREATE INDEX idx_lastlogin_username ON lastlogin (username);
......@@ -2,6 +2,8 @@
// sources:
// migrations/1_initialize_schema.down.sql
// migrations/1_initialize_schema.up.sql
// migrations/2_lastlogin.down.sql
// migrations/2_lastlogin.up.sql
// DO NOT EDIT!
package migrations
......@@ -63,7 +65,7 @@ func _1_initialize_schemaDownSql() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "1_initialize_schema.down.sql", size: 144, mode: os.FileMode(420), modTime: time.Unix(1511642368, 0)}
info := bindataFileInfo{name: "1_initialize_schema.down.sql", size: 144, mode: os.FileMode(420), modTime: time.Unix(1549127471, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
......@@ -119,7 +121,52 @@ func _1_initialize_schemaUpSql() (*asset, error) {
return nil, err
}
info := bindataFileInfo{name: "1_initialize_schema.up.sql", size: 1258, mode: os.FileMode(420), modTime: time.Unix(1511675275, 0)}
info := bindataFileInfo{name: "1_initialize_schema.up.sql", size: 1258, mode: os.FileMode(420), modTime: time.Unix(1549127471, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
var __2_lastloginDownSql = []byte(`DROP INDEX idx_lastlogin_username_service;
DROP INDEX idx_lastlogin_username;
DROP TABLE lastlogin;
`)
func _2_lastloginDownSqlBytes() ([]byte, error) {
return __2_lastloginDownSql, nil
}
func _2_lastloginDownSql() (*asset, error) {
bytes, err := _2_lastloginDownSqlBytes()
if err != nil {
return nil, err
}
info := bindataFileInfo{name: "2_lastlogin.down.sql", size: 100, mode: os.FileMode(420), modTime: time.Unix(1549532976, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
var __2_lastloginUpSql = []byte(`CREATE TABLE lastlogin (
username TEXT NOT NULL,
service TEXT NOT NULL,
timestamp DATETIME NOT NULL
);
CREATE UNIQUE INDEX idx_lastlogin_username_service ON lastlogin (username, service);
CREATE INDEX idx_lastlogin_username ON lastlogin (username);
`)
func _2_lastloginUpSqlBytes() ([]byte, error) {
return __2_lastloginUpSql, nil
}
func _2_lastloginUpSql() (*asset, error) {
bytes, err := _2_lastloginUpSqlBytes()
if err != nil {
return nil, err
}
info := bindataFileInfo{name: "2_lastlogin.up.sql", size: 253, mode: os.FileMode(420), modTime: time.Unix(1549532906, 0)}
a := &asset{bytes: bytes, info: info}
return a, nil
}
......@@ -178,6 +225,8 @@ func AssetNames() []string {
var _bindata = map[string]func() (*asset, error){
"1_initialize_schema.down.sql": _1_initialize_schemaDownSql,
"1_initialize_schema.up.sql": _1_initialize_schemaUpSql,
"2_lastlogin.down.sql": _2_lastloginDownSql,
"2_lastlogin.up.sql": _2_lastloginUpSql,
}
// AssetDir returns the file names below a certain
......@@ -222,6 +271,8 @@ type bintree struct {
var _bintree = &bintree{nil, map[string]*bintree{
"1_initialize_schema.down.sql": &bintree{_1_initialize_schemaDownSql, map[string]*bintree{}},
"1_initialize_schema.up.sql": &bintree{_1_initialize_schemaUpSql, map[string]*bintree{}},
"2_lastlogin.down.sql": &bintree{_2_lastloginDownSql, map[string]*bintree{}},
"2_lastlogin.up.sql": &bintree{_2_lastloginUpSql, map[string]*bintree{}},
}}
// RestoreAsset restores an asset under the given directory
......
......@@ -92,3 +92,35 @@ type GetUserLogsRequest struct {
type GetUserLogsResponse struct {
Results []*LogEntry `json:"result"`
}
type LastLoginEntry struct {
Timestamp time.Time `json:"timestamp"`
Username string `json:"username"`
Service string `json:"service"`
}
func (e *LastLoginEntry) Validate() error {
if e.Username == "" {
return errors.New("invalid last login entry: missing username")
}
if e.Service == "" {
return errors.New("invalid last login entry: missing service")
}
return nil
}
type SetLastLoginRequest struct {
LastLogin *LastLoginEntry `json:"last_login"`
}
type SetLastLoginResponse struct{}
type GetLastLoginRequest struct {
Username string `json:"username"`
Service string `json:"service,omitempty"`
}
type GetLastLoginResponse struct {
Results []*LastLoginEntry `json:"result"`
}
package server
import (
"database/sql"
"errors"
"git.autistici.org/id/usermetadb"
)
var lastloginDBStatements = map[string]string{
"insert_or_replace_last_login": `
INSERT OR REPLACE INTO lastlogin (
username, service, timestamp
) VALUES (
?, ?, ?
)`,
"get_service_last_login": `
SELECT
username, service, timestamp
FROM
lastlogin
WHERE
username = ? AND service = ?`,
"get_last_login": `
SELECT
username, service, timestamp
FROM
lastlogin
WHERE
username = ?
ORDER BY timestamp DESC`,
}
type lastloginDB struct {
db *sql.DB
stmts statementMap
}
func newLastloginDB(db *sql.DB) (*lastloginDB, error) {
stmts, err := newStatementMap(db, lastloginDBStatements)
if err != nil {
return nil, err
}
return &lastloginDB{
db: db,
stmts: stmts,
}, nil
}
func (l *lastloginDB) Close() {
l.stmts.Close()
}
func (l *lastloginDB) AddLastLogin(entry *usermetadb.LastLoginEntry) error {
if entry == nil {
return errors.New("received nil entry")
}
if err := entry.Validate(); err != nil {
return err
}
tx, err := l.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt := "insert_or_replace_last_login"
args := []interface{}{
entry.Username,
entry.Service,
entry.Timestamp,
}
if _, err = l.stmts.get(tx, stmt).Exec(args...); err != nil {
return err
}
return tx.Commit()
}
func (l *lastloginDB) GetLastLogin(username string, service string) ([]*usermetadb.LastLoginEntry, error) {
tx, err := l.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var stmt string
var args []interface{}
if service != "" {
stmt = "get_service_last_login"
args = []interface{}{
username,
service,
}
} else {
stmt = "get_last_login"
args = []interface{}{
username,
}
}
rows, err := l.stmts.get(tx, stmt).Query(args...)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []*usermetadb.LastLoginEntry
for rows.Next() {
e := &usermetadb.LastLoginEntry{}
if err := rows.Scan(
&e.Username,
&e.Service,
&e.Timestamp,
); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
package server
import (
"os"
"testing"
"time"
"git.autistici.org/id/usermetadb"
)
func generateLastLoginEntries() []*usermetadb.LastLoginEntry {
timestamp := time.Now().UTC()
entries := make([]*usermetadb.LastLoginEntry, 0)
for _, u := range []string{"user1", "user2"} {
for _, s := range []string{"service1", "service2"} {
e := &usermetadb.LastLoginEntry{
Timestamp: timestamp,
Username: u,
Service: s,
}
entries = append(entries, e)
}
}
return entries
}
func TestLastlogin_LoginAdded(t *testing.T) {
defer os.Remove("test.db")
db, err := openDB("test.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
ll, err := newLastloginDB(db)
if err != nil {
t.Fatal(err)
}
defer ll.Close()
in := generateLastLoginEntries()
for _, e := range in {
if err := ll.AddLastLogin(e); err != nil {
t.Fatal(err)
}
}
out, err := ll.GetLastLogin(in[0].Username, in[0].Service)
if err != nil {
t.Fatal(err)
}
if len(out) != 1 {
t.Fatalf("Expected exactly one entry, found %v", out)
}
if *in[0] != *out[0] {
t.Fatalf("Last login entries differ:\nSet %v\nGot %v", *in[0], *out[0])
}
}
func TestLastLogin_MultipleServices(t *testing.T) {
defer os.Remove("test.db")
db, err := openDB("test.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
ll, err := newLastloginDB(db)
if err != nil {
t.Fatal(err)
}
defer ll.Close()
in := generateLastLoginEntries()
for _, e := range in {
if err := ll.AddLastLogin(e); err != nil {
t.Fatal(err)
}
}
out, err := ll.GetLastLogin(in[0].Username, "")
if err != nil {
t.Fatal(err)
}
if len(out) != 2 {
t.Fatalf("Expected exactly two entries, found %v", out)
}
for i := 0; i < 2; i++ {
if *in[i] != *out[i] {
t.Fatalf("Last login entries #%d differ:\nSet %v\nGot %v", i, *in[i], *out[i])
}
}
}
func TestLastLogin_NoUser(t *testing.T) {
defer os.Remove("test.db")
db, err := openDB("test.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
ll, err := newLastloginDB(db)
if err != nil {
t.Fatal(err)
}
defer ll.Close()
in := generateLastLoginEntries()
for _, e := range in {
if err := ll.AddLastLogin(e); err != nil {
t.Fatal(err)
}
}
out, err := ll.GetLastLogin("user3", "")
if err != nil {
t.Fatal(err)
}
if len(out) != 0 {
t.Fatalf("Expected no entries, found %v", out)
}
}
......@@ -18,10 +18,11 @@ type Config struct {
// UserMetaServer exposes the analysis service and the user metadata
// database over an HTTP API.
type UserMetaServer struct {
db *sql.DB
config *Config
analysis *analysisService
userlog *userlogDB
db *sql.DB
config *Config
analysis *analysisService
userlog *userlogDB
lastlogin *lastloginDB
}
// New returns a new UserMetaServer with the given configuration.
......@@ -43,11 +44,18 @@ func New(config *Config) (*UserMetaServer, error) {
return nil, err
}
lastlogin, err := newLastloginDB(db)
if err != nil {
db.Close() // nolint
return nil, err
}
return &UserMetaServer{
db: db,
config: config,
analysis: analysis,
userlog: userlog,
db: db,
config: config,
analysis: analysis,
userlog: userlog,
lastlogin: lastlogin,
}, nil
}
......@@ -55,6 +63,7 @@ func New(config *Config) (*UserMetaServer, error) {
func (s *UserMetaServer) Close() {
s.analysis.Close()
s.userlog.Close()
s.lastlogin.Close()
s.db.Close() // nolint
}
......@@ -121,6 +130,37 @@ func (s *UserMetaServer) handleGetUserLogs(w http.ResponseWriter, r *http.Reques
serverutil.EncodeJSONResponse(w, &usermetadb.GetUserLogsResponse{Results: entries})
}
func (s *UserMetaServer) handleSetLastLogin(w http.ResponseWriter, r *http.Request) {
var req usermetadb.SetLastLoginRequest
if !serverutil.DecodeJSONRequest(w, r, &req) {
return
}
if err := s.lastlogin.AddLastLogin(req.LastLogin); err != nil {
log.Printf("AddLastLogin error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
serverutil.EncodeJSONResponse(w, &usermetadb.SetLastLoginResponse{})
}
func (s *UserMetaServer) handleGetLastLogin(w http.ResponseWriter, r *http.Request) {
var req usermetadb.GetLastLoginRequest
if !serverutil.DecodeJSONRequest(w, r, &req) {
return
}
entries, err := s.lastlogin.GetLastLogin(req.Username, req.Service)
if err != nil {
log.Printf("GetLastLogin error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
serverutil.EncodeJSONResponse(w, &usermetadb.GetLastLoginResponse{Results: entries})
}
// Handler returns a http.Handler for the HTTP API.
func (s *UserMetaServer) Handler() http.Handler {
h := http.NewServeMux()
......@@ -128,5 +168,7 @@ func (s *UserMetaServer) Handler() http.Handler {
h.HandleFunc("/api/add_log", s.handleAddLog)
h.HandleFunc("/api/get_user_devices", s.handleGetUserDevices)
h.HandleFunc("/api/get_user_logs", s.handleGetUserLogs)
h.HandleFunc("/api/set_last_login", s.handleSetLastLogin)
h.HandleFunc("/api/get_last_login", s.handleGetLastLogin)
return h
}
......@@ -49,3 +49,32 @@ func BenchmarkServer_AddLog(b *testing.B) {
}
}
}
func TestServer_AddLastLogin(t *testing.T) {
defer os.Remove("test.db")
srv, err := New(&Config{DBURI: "test.db"})
if err != nil {
t.Fatal("New", err)
}
httpSrv := httptest.NewServer(srv.Handler())
defer httpSrv.Close()
c, _ := client.New(&clientutil.BackendConfig{URL: httpSrv.URL})
entries := generateLastLoginEntries()
for _, e := range entries {
if err := c.SetLastLogin(context.Background(), "", e); err != nil {
t.Fatalf("SetLastLogin(%+v): %v", e, err)
}
}
exp_resp := *entries[0]
resp, err := c.GetLastLogin(context.Background(), "", exp_resp.Username, exp_resp.Service)
if err != nil {
t.Fatal(err)
}
if *resp[0] != exp_resp {
t.Fatalf("Last login entries differ:\nSet %v\nGot %v", exp_resp, *resp[0])
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment