├── .env ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── authgraph.png ├── authtables.go ├── build_test.go ├── configuration.go ├── datastore.go ├── docker-compose.yml ├── example.gif ├── graph.png ├── scripts └── test └── visual.png /.env: -------------------------------------------------------------------------------- 1 | AUTHTABLES_HOST=redis 2 | AUTHTABLES_PORT=6379 3 | AUTHTABLES_LOGLEVEL=debug 4 | AUTHTABLES_BLOOMSIZE=1000000000 5 | AUTHTABLES_SHARD= 6 | #AUTHTABLES_PW= 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | *.swp 3 | 4 | scripts/test_data 5 | 6 | scripts/test_data.go 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - tip 4 | services: 5 | - docker 6 | - redis-server 7 | after_success: 8 | - docker-compose build 9 | - docker-compose up -d 10 | - ./scripts/test 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.7 2 | 3 | # Create a workspace 4 | RUN mkdir -p /opt/authtables 5 | WORKDIR /opt/authtables 6 | 7 | # install deps 8 | RUN go get github.com/willf/bloom \ 9 | gopkg.in/redis.v4 \ 10 | github.com/Sirupsen/logrus 11 | 12 | # Add our files 13 | ADD authtables.go authtables.go 14 | ADD .env .env 15 | ADD configuration.go configuration.go 16 | ADD datastore.go datastore.go 17 | 18 | # Build app 19 | RUN go build authtables.go configuration.go datastore.go 20 | 21 | # Default runs on 8080 22 | EXPOSE 8080 23 | 24 | # Run our binary 25 | CMD /opt/authtables/authtables 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Ryan McGeehan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AuthTables 2 | (no longer maintained!) 3 | [![Build Status](https://travis-ci.org/magoo/AuthTables.svg?branch=master)](https://travis-ci.com/magoo/AuthTables) [![Go Report Card](https://goreportcard.com/badge/github.com/magoo/AuthTables)](https://goreportcard.com/report/github.com/magoo/AuthTables) 4 | 5 | AuthTables is a service that detects the possibility of "Account Take Over" (ATO) caused by remote credential theft and reuse. If bad actors are stealing your users passwords, AuthTables may be useful. 6 | 7 | After a successful authentication attempt, AuthTables can very simply respond to your app with `BAD` if it hasn't seen the user on the IP or device identifier before. You can then challenge the user with MFA, an email confirmation, or other verification. If it has seen the user, it will respond with `OK` and you have greater assurance that the user hasn't been compromised by a basic ATO (See "Threat") 8 | 9 | ![](authgraph.png) 10 | 11 | AuthTables depends on no external feeds of data, risk scores, or machine learning. Your own authentication data will generate a graph of known location records for a user as they authenticate with known cookies or IP addresses. Every new login from a previously known IP or Cookie makes this graph stronger over time as it adds new record for the user, reducing their friction and increasing their security. 12 | 13 | Read more about this strategy [here](https://medium.com/starting-up-security/preventing-account-takeover-c914fa07fb45#.pm66h84hi). 14 | 15 | AuthTables relies on an in memory [bloom filter](https://en.wikipedia.org/wiki/Bloom_filter) allowing extremely fast responses while storing historical user location records to redis for backups and fraud investigations. 16 | 17 | ## Potential Implementations 18 | AuthTables only tells you if a login is completely unrecognized or not. It's up to you to build your application to do the following with `BAD` logins: 19 | 20 | - Hook up a Slack bot to notify employees that a totally new IP / Device logged into their account. 21 | - Force an IP that is frequently authenticating as `BAD` to solve CAPTCHA's. 22 | - Disable sensitive features until MFA, SMS, or email verification occurs, like a BTC withdraw. 23 | - Do a `count(IP)` across all of your suspicious logins and surface high volume bad actors for review. 24 | - Notify other open sessions if the new `BAD` session is ok. 25 | 26 | While AuthTables will "Trust on First Use" (TOFU), it's up to your applications to `/add` new authentication data to create a positive feedback loop. For instance, every MFA challenge that succeeds should `/add` the auth to AuthTables to improve signal. 27 | 28 | Here's how AuthTables looks when it is logging authentications. 29 | 30 | ![](example.gif) 31 | 32 | ## The Threat 33 | 34 | AuthTables is solely focused on the most common credential theft and reuse vector. Specifically, this is when an attacker has a victim's username and password, but they are not on the victim's host or network. This specific threat _absolutely cannot operate_ within the known graph of users historical records, unless they are a localized account takeover threat (malware, etc) 35 | 36 | Remote credential reuse is the most common and most accessible threat that results from large credential dumps and shared passwords. 37 | 38 | ![](visual.png) 39 | 40 | Far more than half of the abuse issues related to ATO are remote credential reuse due to its ease of exploitation. The constellation of other problems (local malware, malicious browser extensions, MITM) usually make up the rest at most companies, and are not in scope of AuthTables. 41 | 42 | AuthTables focuses solely on this largest problem, and logically reduces the possibility that an authentication is ATO'd by making it clear that the auth came from a known device or record that a remote attacker couldn't possibly have used. 43 | 44 | If fraud *does occur* after your systems have challenged a `BAD` user, you can logically conclude that the user has suffered a much more significant compromise than a remote credential theft. 45 | 46 | ## Opportunity 47 | The attack limitations of simple credential thief creates an opportunity for us to build an ever growing graph of known records a user authenticates from. A credential thief is limited to operating outside of this graph, thus allowing us to treat those authentication with suspicion. 48 | 49 | ![image](graph.png) 50 | 51 | Your application may have methods to verify these suspicious records and `/add` them the user's graph: 52 | 53 | - Verification over email 54 | - Out of band SMS 55 | - Multifactor authentications 56 | - Threat feeds (known proxies, Tor, known data center, etc) 57 | - Manual intervention from customer support 58 | - Older logins that have never been abusive 59 | 60 | These are example verifications that remote credential thieves will have significant hurdles or friction to manipulate, allowing you to increase the size of your users known graph. You'll do this by sending verified record to `/add`. 61 | 62 | Additional verifications are entirely dependent on your own risk tolerance. A bitcoin company, for instance, may require true MFA to add a record, whereas a social website may `/add` a record to the users graph if they've clicked on a link in their email. 63 | 64 | AuthTables assumes that your authentication service assigns as-static-as-possible cookies or identifiers to your users clients, as their personal devices will reveal new IP addresses they are likely to authenticate from. 65 | 66 | This allows less friction to the user and greatly reduces the need to prompt for MFA or other out-of-band-verifications. It also strongly identifies when a user is compromised by a more powerful, localized attack, or ATO of their registration email, allowing for much easier support scenarios to mitigate the user once you've eliminated remote credential reuse as a possibility. 67 | 68 | ## Detection 69 | It's entirely possible to limit AuthTables to only logging duty with no interference or interaction with your users. Implement custom alerting on your logs and can discover IP addresses or machine identifiers that are frequently appearing as suspicious logins which may surface high scale ATO driven attacks on your application. 70 | 71 | ## Protocol 72 | 73 | AuthTables receives JSON POSTs to `/check` containing UID, IP, and Machine ID. 74 | 75 | `{ 76 | "ip":"1.1.1.1", 77 | "mid":"uniqueidentifier", 78 | "uid":"magoo" 79 | }` 80 | 81 | AuthTables quickly responds whether this is a known record for the user. If either MID or IP is new, it will add this to their known record (Response: "OK") which grows their graph. If both are new, there is significant possibility that this account is taken over (Response: "BAD"), and should trigger a multifactor or email confirmation or other way of mitigating risk of ATO for this session. After this challenge, you can `/add` the session to their graph, allowing them to operate in the future without challenges. 82 | 83 | ## Limitations 84 | 85 | - Extra Paranoid users who frequently change hosts and clear cookies (VPN's and Incognito) will frequently appear as credential thiefs. A VPN switch alone or an incognito browser alone will not appear suspicious, but we cannot `OK` a complete change of appearance (both). 86 | - Authentications from users victimized by localized attacks (like malware, see "Threats") require very different approaches, as the adversary will have access to their local machine identification and network, bypassing AuthTables detection. 87 | - AuthTables depends on your application to challenge users who appears suspicious, and `ADD`ing their location after verification. However, methods outside of true MFA may have their own bypasses. For instance, email confirmation may suffer from a shared password with the original victim, allowing an attacker to confirm a new record for themselves. 88 | - In-Person account takeover, like "Friendly Fraud" or the "Malicious Family Member" bypasses AuthTables. Localized, personal attacks may share a laptop or wifi, both of which would bypass protections from AuthTables. 89 | 90 | ## Running With Docker 91 | Install docker / compose: https://docs.docker.com/compose/install/ 92 | 93 | ```bash 94 | # build the container 95 | docker-compose build 96 | # run with a local redis 97 | docker-compose up 98 | # send a test command (assumes docker is bound to localhost) 99 | curl localhost:8080/check \ 100 | -H "Content-Type: application/json" \ 101 | -XPOST -d \ 102 | '{ "ip":"1.1.1.1","mid":"my-device","uid":"magoo"}' 103 | > OK 104 | curl localhost:8080/check \ 105 | -H "Content-Type: application/json" \ 106 | -XPOST -d \ 107 | '{ "ip":"2.2.2.2","mid":"bad-device","uid":"magoo"}' 108 | > BAD 109 | ``` 110 | 111 | See more examples in `/scripts` for local testing. 112 | 113 | 114 | -------------------------------------------------------------------------------- /authgraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magoo/AuthTables/61d9e3d8118be09366514d3d5add38ae1e821f80/authgraph.png -------------------------------------------------------------------------------- /authtables.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | log "github.com/Sirupsen/logrus" 7 | "gopkg.in/redis.v4" 8 | "io/ioutil" 9 | "net/http" 10 | "regexp" 11 | "time" 12 | ) 13 | 14 | //Main 15 | func main() { 16 | 17 | //First time online, load historical data for bloom 18 | loadRecords() 19 | 20 | //Configure log Loglevel 21 | 22 | //Announce that we're running 23 | log.Info("AuthTables is running.") 24 | //Add routes, then open a webserver 25 | http.HandleFunc("/add", addRequest) 26 | http.HandleFunc("/check", checkRequest) 27 | http.HandleFunc("/reset", resetRequest) 28 | log.Error(http.ListenAndServe(":8080", nil)) 29 | 30 | } 31 | 32 | func getRecordHashesFromRecord(rec Record) (recordhashes RecordHashes) { 33 | 34 | rh := RecordHashes{ 35 | uid: []byte(c.Shard + rec.Uid), 36 | uidMID: []byte(fmt.Sprintf(c.Shard + "%s:%s", rec.Uid, rec.Mid)), 37 | uidIP: []byte(fmt.Sprintf(c.Shard + "%s:%s", rec.Uid, rec.Ip)), 38 | uidALL: []byte(fmt.Sprintf(c.Shard + "%s:%s:%s", rec.Uid, rec.Ip, rec.Mid)), 39 | ipMID: []byte(fmt.Sprintf(c.Shard + "%s:%s", rec.Ip, rec.Mid)), 40 | midIP: []byte(fmt.Sprintf(c.Shard + "%s:%s", rec.Mid, rec.Ip)), 41 | } 42 | 43 | return rh 44 | } 45 | 46 | func check(rec Record) (b bool) { 47 | //We've received a request to /check and now 48 | //we need to see if it's suspicious or not. 49 | 50 | //Create []byte Strings for bloom 51 | rh := getRecordHashesFromRecord(rec) 52 | 53 | //These is ip:mid and mid:ip, useful for `key` 54 | //commands hunting for other bad guys. This May 55 | //be a separate db, sharded elsewhere in the future. 56 | //Example: `key 1.1.1.1:*` will reveal new machine ID's 57 | //seen on this host. 58 | //This may include evil data, which is why we don't attach to a user. 59 | writeRecord(rh.ipMID) 60 | writeRecord(rh.midIP) 61 | 62 | //Do we have it in bloom? 63 | //if filter.Test([]byte(r.URL.Path[1:])) { 64 | if filter.Test(rh.uidALL) { 65 | //We've seen everything about this user before. MachineID, IP, and user. 66 | log.WithFields(log.Fields{ 67 | "uid": rec.Uid, 68 | "mid": rec.Mid, 69 | "ip": rec.Ip, 70 | }).Debug("Known user information.") 71 | 72 | //Write Everything. 73 | //defer writeUserRecord(rh) 74 | return true 75 | } else if (filter.Test(rh.uidMID)) || (filter.Test(rh.uidIP)) { 76 | 77 | log.WithFields(log.Fields{ 78 | "uid": rec.Uid, 79 | "mid": rec.Mid, 80 | "ip": rec.Ip, 81 | }).Debug("Authentication is partially within graph. Expanding graph.") 82 | defer writeUserRecord(rh) 83 | return true 84 | 85 | } else if !(filter.Test(rh.uid)) { 86 | 87 | log.WithFields(log.Fields{ 88 | "uid": rec.Uid, 89 | "mid": rec.Mid, 90 | "ip": rec.Ip, 91 | }).Debug("New user. Creating graph") 92 | 93 | defer writeUserRecord(rh) 94 | return true 95 | 96 | } else { 97 | 98 | log.WithFields(log.Fields{ 99 | "uid": rec.Uid, 100 | "mid": rec.Mid, 101 | "ip": rec.Ip, 102 | }).Info("Suspicious authentication.") 103 | return false 104 | } 105 | 106 | } 107 | 108 | func isStringSane(s string) (b bool) { 109 | 110 | matched, err := regexp.MatchString("^[A-Za-z0-9.]{0,60}$", s) 111 | if err != nil { 112 | fmt.Println(err) 113 | } 114 | 115 | return matched 116 | } 117 | 118 | func isRecordSane(r Record) (b bool) { 119 | 120 | return (isStringSane(r.Mid) && isStringSane(r.Ip) && isStringSane(r.Uid)) 121 | 122 | } 123 | func sanitizeError() { 124 | log.Warn("Bad data received. Sanitize fields in application before sending to remove this message.") 125 | } 126 | 127 | func requestToJSON(r *http.Request) (m Record) { 128 | //Get our body from the request (which should be JSON) 129 | err := r.ParseForm() 130 | if err != nil { 131 | fmt.Println("error:", err) 132 | log.Warn("Trouble parsing the form from the request") 133 | } 134 | 135 | body, err := ioutil.ReadAll(r.Body) 136 | if err != nil { 137 | fmt.Println("error:", err) 138 | log.Warn("Trouble reading JSON from request") 139 | } 140 | 141 | //Cast our JSON body content to prepare for Unmarshal 142 | clientAuthdata := []byte(body) 143 | 144 | //Decode some JSON and get it into our Record struct 145 | var rec Record 146 | err = json.Unmarshal(clientAuthdata, &rec) 147 | if err != nil { 148 | log.Warn("Trouble with Unmarhal of JSON received from client.") 149 | } 150 | 151 | return rec 152 | } 153 | 154 | //Main routing handlers 155 | func addRequest(w http.ResponseWriter, r *http.Request) { 156 | var m Record 157 | m = requestToJSON(r) 158 | 159 | if isRecordSane(m) { 160 | log.WithFields(log.Fields{ 161 | "uid": m.Uid, 162 | "mid": m.Mid, 163 | "ip": m.Ip, 164 | }).Debug("Adding user.") 165 | 166 | if add(m) { 167 | fmt.Fprint(w, "ADD") 168 | } else { 169 | fmt.Fprint(w, "ADD") 170 | log.Error("Something went wrong adding user.") 171 | } //Currently we fail open. 172 | } else { 173 | sanitizeError() 174 | } 175 | 176 | } 177 | 178 | func add(rec Record) (b bool) { 179 | 180 | //JSON record is sent to /add, we add all of it to bloom. 181 | rh := getRecordHashesFromRecord(rec) 182 | defer writeUserRecord(rh) 183 | return true 184 | } 185 | 186 | func resetRequest(w http.ResponseWriter, r *http.Request) { 187 | fmt.Fprint(w, "RESET") 188 | defer loadRecords() 189 | } 190 | 191 | func checkRequest(w http.ResponseWriter, r *http.Request) { 192 | var m Record 193 | m = requestToJSON(r) 194 | 195 | //Only let sane data through the gate. 196 | if isRecordSane(m) { 197 | 198 | if check(m) { 199 | fmt.Fprint(w, "OK") 200 | } else { 201 | fmt.Fprint(w, "BAD") 202 | } 203 | } else { 204 | //We hit this if nasty JSON data came through. Shouldn't touch bloom or redis. 205 | //To remove this message, don't let your application send UID, IP, or MID that doesn't match "^[A-Za-z0-9.]{0,60}$" 206 | sanitizeError() 207 | fmt.Fprintln(w, "BAD") 208 | } 209 | } 210 | 211 | func writeRecord(key []byte) { 212 | 213 | err := client.Set(string(key), 1, 0).Err() 214 | if err != nil { 215 | //(TODO Try to make new connection) 216 | rebuildConnection() 217 | 218 | log.WithFields(log.Fields{ 219 | "error": err, 220 | }).Error("Problem connecting to database.") 221 | 222 | } 223 | 224 | } 225 | 226 | func rebuildConnection() { 227 | log.Debug("Attempting to reconnect...") 228 | client = redis.NewClient(&redis.Options{ 229 | Addr: c.Host + ":" + c.Port, 230 | Password: c.Password, // no password set 231 | DB: 0, // use default DB 232 | }) 233 | } 234 | 235 | func loadRecords() { 236 | timeTrack(time.Now(), "Loading records") 237 | 238 | var cursor uint64 239 | var n int 240 | 241 | //Empty our filter before re-filling 242 | filter.ClearAll() 243 | for { 244 | var keys []string 245 | var err error 246 | //The shard name is pulled from config. We don't want to waste time on records that won't be asked of us. 247 | keys, cursor, err = client.Scan(cursor, c.Shard + "*", 10).Result() 248 | if err != nil { 249 | log.Error("Could not connect to database. Continuing without records") 250 | break 251 | } 252 | n += len(keys) 253 | 254 | for _, element := range keys { 255 | filter.Add([]byte(element)) 256 | } 257 | 258 | if cursor == 0 { 259 | break 260 | } 261 | } 262 | log.WithFields(log.Fields{ 263 | "number": n, 264 | }).Debug("Loaded historical records.") 265 | } 266 | 267 | func canGetKey(s string) bool { 268 | err := client.Get(s).Err() 269 | if err != nil { 270 | return false 271 | } 272 | return true 273 | } 274 | 275 | func writeUserRecord(rh RecordHashes) { 276 | 277 | err := client.MSet(string(rh.uid), 1, string(rh.uidMID), 1, string(rh.uidIP), 1, string(rh.uidALL), 1).Err() 278 | if err != nil { 279 | log.Error("MSet failed.") 280 | log.Error(err) 281 | } 282 | 283 | //Bloom 284 | filter.Add(rh.uidMID) 285 | filter.Add(rh.uidIP) 286 | filter.Add(rh.uid) 287 | filter.Add(rh.uidALL) 288 | } 289 | 290 | func timeTrack(start time.Time, name string) { 291 | elapsed := time.Since(start) 292 | log.WithFields(log.Fields{ 293 | "time": elapsed.String(), 294 | "event": name, 295 | }).Debug("Time tracked") 296 | } 297 | 298 | //Only using init to configure logging. See configuration.go 299 | func init() { 300 | level, err := log.ParseLevel(c.Loglevel) 301 | if err != nil { 302 | log.Error("Issue setting log level. Make sure log level is a string: debug, warn, info, error, panic") 303 | } 304 | log.SetLevel(level) 305 | } 306 | -------------------------------------------------------------------------------- /build_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "log" 9 | "io/ioutil" 10 | "github.com/willf/bloom" 11 | "bytes" 12 | "encoding/json" 13 | ) 14 | 15 | var testRec = Record{ 16 | Uid: "testUID", 17 | Mid: "testMID", 18 | Ip: "1.1.1.1", 19 | } 20 | 21 | var filterTest = bloom.NewWithEstimates(c.BloomSize, 1e-3) // Configurable in environment var. 22 | 23 | func TestRedisConnectivity (t *testing.T) { 24 | 25 | _, err := client.Ping().Result() 26 | if err != nil { 27 | t.Errorf("We can't ping redis.") 28 | } 29 | } 30 | 31 | func TestPrintLine(t *testing.T) { 32 | // test stuff here... 33 | fmt.Println("Print line works, so there's that.") 34 | } 35 | 36 | func TestLoad(t *testing.T) { 37 | writeRecord([]byte("asdf")) 38 | } 39 | 40 | func TestWWWServer(t *testing.T) { 41 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 42 | fmt.Fprintln(w, "Testing the client") 43 | })) 44 | defer ts.Close() 45 | 46 | res, err := http.Get(ts.URL) 47 | if err != nil { 48 | log.Fatal(err) 49 | } 50 | 51 | greeting, err := ioutil.ReadAll(res.Body) 52 | if err != nil { 53 | log.Fatal(err) 54 | } 55 | 56 | err = res.Body.Close() 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | 61 | fmt.Printf("%s", greeting) 62 | } 63 | 64 | func TestBloom(t *testing.T) { 65 | if filterTest.Test([]byte("shouldnotexist")) { 66 | log.Fatal("Bloom filter detected a string that wasn't in filter") 67 | } 68 | filterTest.Add([]byte("exists")) 69 | if !filterTest.Test([]byte("exists")) { 70 | log.Fatal("Bloom filter could not detect a string that was in filter") 71 | } 72 | 73 | } 74 | 75 | func TestCheckRequest(t *testing.T) { 76 | req, err := http.NewRequest("POST", "/check", bytes.NewBuffer(testRec.Marshaler())) 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | 81 | rr := httptest.NewRecorder() 82 | handler := http.HandlerFunc(checkRequest) 83 | 84 | handler.ServeHTTP(rr, req) 85 | 86 | // Correct Response? 87 | if status := rr.Code; status != http.StatusOK { 88 | t.Errorf("handler returned wrong status code: got %v want %v", 89 | status, http.StatusOK) 90 | } 91 | // Check the response body is what we expect. 92 | expected := `OK` 93 | if rr.Body.String() != expected { 94 | t.Errorf("handler returned unexpected body: got %v want %v", 95 | rr.Body.String(), expected) 96 | } 97 | } 98 | 99 | func TestResetRequest(t *testing.T) { 100 | req, err := http.NewRequest("POST", "/reset", bytes.NewBuffer(testRec.Marshaler())) 101 | if err != nil { 102 | t.Fatal(err) 103 | } 104 | 105 | rr := httptest.NewRecorder() 106 | handler := http.HandlerFunc(resetRequest) 107 | 108 | handler.ServeHTTP(rr, req) 109 | 110 | // Correct Response? 111 | if status := rr.Code; status != http.StatusOK { 112 | t.Errorf("handler returned wrong status code: got %v want %v", 113 | status, http.StatusOK) 114 | } 115 | // Check the response body is what we expect. 116 | expected := `RESET` 117 | if rr.Body.String() != expected { 118 | t.Errorf("handler returned unexpected body: got %v want %v", 119 | rr.Body.String(), expected) 120 | } 121 | } 122 | 123 | func TestAddRequest(t *testing.T) { 124 | 125 | jsonStr, err := json.Marshal(testRec) 126 | if err != nil { 127 | t.Fatal(err) 128 | } 129 | 130 | req, err := http.NewRequest("POST", "/add", bytes.NewBuffer(jsonStr)) 131 | if err != nil { 132 | t.Fatal(err) 133 | } 134 | 135 | rr := httptest.NewRecorder() 136 | handler := http.HandlerFunc(addRequest) 137 | 138 | handler.ServeHTTP(rr, req) 139 | 140 | // Correct Response? 141 | if status := rr.Code; status != http.StatusOK { 142 | t.Errorf("handler returned wrong status code: got %v want %v", 143 | status, http.StatusOK) 144 | } 145 | // Check the response body is what we expect. 146 | expected := `ADD` 147 | if rr.Body.String() != expected { 148 | t.Errorf("handler returned unexpected body: got %v want %v", 149 | rr.Body.String(), expected) 150 | } 151 | // Check a key was written 152 | if !(canGetKey("testUID:1.1.1.1") && canGetKey("testUID:testMID")) { 153 | t.Errorf("keys not being written") 154 | } 155 | 156 | } 157 | 158 | func BenchmarkBloomAdd(b *testing.B) { 159 | for i := 0; i < b.N; i++ { 160 | // We reset the timer because the bloom filter is only created on boot. 161 | //b.ResetTimer() 162 | filterTest.Add([]byte("exists")) 163 | } 164 | } 165 | 166 | func BenchmarkBloomTest(b *testing.B) { 167 | for i := 0; i < b.N; i++ { 168 | // We reset the timer because the bloom filter is only created on boot. 169 | filter.Test([]byte("shouldnotexist")) 170 | } 171 | } 172 | 173 | func BenchmarkWriteRecord(b *testing.B) { 174 | for i := 0; i < b.N; i++ { 175 | add(testRec) 176 | } 177 | } 178 | 179 | func BenchmarkReadRecord(b *testing.B) { 180 | for i := 0; i < b.N; i++ { 181 | check(testRec) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /configuration.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "strconv" 7 | log "github.com/Sirupsen/logrus" 8 | ) 9 | 10 | //Configuration holds data cleaned from our ENV variables or passed through cmd line 11 | type Configuration struct { 12 | Host string 13 | Port string 14 | Password string 15 | Loglevel string 16 | BloomSize uint 17 | Shard string 18 | } 19 | 20 | //Global access to configuration variables 21 | var c = readConfig() 22 | 23 | func readConfig() (c Configuration) { 24 | //Command Line Flags. If command line flag is blank, use ENV instead 25 | var flagHost string 26 | flag.StringVar(&flagHost, "host", os.Getenv("AUTHTABLES_HOST"), "hostname for redis") 27 | var flagPort string 28 | flag.StringVar(&flagPort, "port", os.Getenv("AUTHTABLES_PORT"), "port for redis") 29 | var flagPW string 30 | flag.StringVar(&flagPW, "password", os.Getenv("AUTHTABLES_PW"), "password for redis") 31 | var flagLoglevel string 32 | flag.StringVar(&flagLoglevel, "loglevel", os.Getenv("AUTHTABLES_LOGLEVEL"), "level of logging (debug, info, warn, error)") 33 | var flagBloomSize uint 34 | d, _ := strconv.ParseUint(os.Getenv("AUTHTABLES_BLOOMSIZE"), 0, 32) 35 | flag.UintVar(&flagBloomSize, "bloomsize", uint(d), "size of bloom filter (default 1e9)") 36 | var flagShard string 37 | flag.StringVar(&flagShard, "shard", os.Getenv("AUTHTABLES_SHARD"), "name of this shard (prefix's keys within redis)") 38 | 39 | flag.Parse() 40 | 41 | if (flagHost == "" || flagPort == "" || flagLoglevel == "" || flagBloomSize == 0) { 42 | log.Error("Important things are not configured. You need to have your environment variables set, a .env file (docker), or pass valid data in command line arguments.") 43 | } 44 | 45 | 46 | //We're going to load this with config data. See struct! 47 | configuration := Configuration{} 48 | 49 | configuration.Host = flagHost 50 | configuration.Port = flagPort 51 | configuration.Password = flagPW 52 | configuration.Loglevel = flagLoglevel 53 | configuration.BloomSize = flagBloomSize 54 | configuration.Shard = flagShard 55 | 56 | return configuration 57 | } 58 | -------------------------------------------------------------------------------- /datastore.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/willf/bloom" 5 | "gopkg.in/redis.v4" 6 | "encoding/json" 7 | log "github.com/Sirupsen/logrus" 8 | "fmt" 9 | ) 10 | 11 | //Bloom Filter 12 | var filter = bloom.NewWithEstimates(c.BloomSize, 1e-3) // Configurable in environment var. 13 | 14 | //Record is the main struct that is passed from applications to AuthTables as JSON. 15 | //Applications send us these, and AuthTables responds with `OK`s or `BAD` 16 | type Record struct { 17 | Uid string `json:"uid"` 18 | Ip string `json:"ip"` 19 | Mid string `json:"mid"` 20 | } 21 | 22 | func (r Record) Marshaler() []byte { 23 | 24 | json, err := json.Marshal(r) 25 | if err != nil { 26 | log.Error("Issue marshal'ing json") 27 | } 28 | fmt.Println(string(json)) 29 | 30 | return json 31 | 32 | } 33 | 34 | //RecordHashes is a struct ready for use in the bloom filter or redis. 35 | type RecordHashes struct { 36 | uid []byte 37 | uidMID []byte 38 | uidIP []byte 39 | uidALL []byte 40 | ipMID []byte 41 | midIP []byte 42 | } 43 | 44 | //Take us online to Redis 45 | var client = redis.NewClient(&redis.Options{ 46 | Addr: c.Host + ":" + c.Port, 47 | Password: c.Password, // no password set 48 | DB: 0, // use default DB 49 | }) 50 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | authtables: 2 | build: . 3 | ports: 4 | - "8080:8080" 5 | links: 6 | - redis 7 | env_file: .env 8 | redis: 9 | image: redis 10 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magoo/AuthTables/61d9e3d8118be09366514d3d5add38ae1e821f80/example.gif -------------------------------------------------------------------------------- /graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magoo/AuthTables/61d9e3d8118be09366514d3d5add38ae1e821f80/graph.png -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RAND=$RANDOM 4 | 5 | function check { 6 | RESULT=$(curl localhost:8080/check \ 7 | -s \ 8 | -H "Content-Type: application/json" \ 9 | -XPOST -d \ 10 | "{ \"ip\":\"$1\", 11 | \"mid\":\"$2\", 12 | \"uid\":\"$RAND\" 13 | }") 14 | 15 | if [ "$RESULT" != "$3" ]; then 16 | echo "Failed. Received: $RESULT Expected: $3." 17 | exit 1 18 | fi 19 | 20 | } 21 | 22 | function add { 23 | RESULT=$(curl localhost:8080/add \ 24 | -s \ 25 | -H "Content-Type: application/json" \ 26 | -XPOST -d \ 27 | "{ \"ip\":\"$1\", 28 | \"mid\":\"$2\", 29 | \"uid\":\"$RAND\" 30 | }") 31 | 32 | if [ "$RESULT" != "$3" ]; then 33 | echo "Failed. Received: $RESULT Expected: $3." 34 | exit 1 35 | fi 36 | } 37 | 38 | #function rst { 39 | # RESULT=$(curl localhost:8080/reset -s \ 40 | # -H "Content-Type: application/json" \ 41 | # ) 42 | # 43 | # if [ "$RESULT" != "$1" ]; then 44 | # echo "Failed. Received: $RESULT Expected: $1." 45 | # exit 1 46 | # fi 47 | #} 48 | 49 | echo Brand new user. Begin their graph. 50 | check "1.1.1.1" "COOKIEONE" "OK" 51 | 52 | echo Login with new computers in the same house 53 | check "1.1.1.1" "COOKIETWO" "OK" 54 | check "1.1.1.1" "COOKIETHREE" "OK" 55 | check "1.1.1.1" "COOKIEFOUR" "OK" 56 | 57 | add "5.5.5.5" "NEWCOOKIE" "ADD" 58 | 59 | echo Login from work with laptop 60 | check "2.2.2.2" "COOKIETWO" "OK" 61 | 62 | echo Suspicious Login! 63 | check "4.4.4.4" "BADCOOKIE" "BAD" 64 | 65 | echo Bad Data! 66 | check "