├── .devcontainer ├── configuration.yaml └── devcontainer.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── issue.md ├── release-drafter.yml ├── settings.yml └── workflows │ ├── lint.yml │ ├── release-drafter.yml │ ├── release.yaml │ └── validate.yml ├── .gitignore ├── .vscode └── tasks.json ├── LICENSE ├── README.md ├── custom_components └── authenticated │ ├── __init__.py │ ├── const.py │ ├── manifest.json │ ├── providers.py │ └── sensor.py ├── hacs.json ├── img ├── overview.png └── persistant_notification.png └── info.md /.devcontainer/configuration.yaml: -------------------------------------------------------------------------------- 1 | default_config: 2 | logger: 3 | default: info 4 | logs: 5 | custom_components.authenticated: debug 6 | 7 | 8 | sensor: 9 | - platform: authenticated -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Custom integration authenticated", 3 | "image": "ghcr.io/ludeeus/devcontainer/integration:stable", 4 | "context": "..", 5 | "appPort": [ 6 | "9123:8123" 7 | ], 8 | "postCreateCommand": "container install", 9 | "extensions": [ 10 | "ms-python.python", 11 | "github.vscode-pull-request-github", 12 | "ryanluker.vscode-coverage-gutters", 13 | "ms-python.vscode-pylance" 14 | ], 15 | "settings": { 16 | "files.eol": "\n", 17 | "editor.tabSize": 4, 18 | "terminal.integrated.shell.linux": "/bin/bash", 19 | "python.pythonPath": "/usr/bin/python3", 20 | "python.linting.pylintEnabled": true, 21 | "python.linting.enabled": true, 22 | "python.formatting.provider": "black", 23 | "editor.formatOnPaste": false, 24 | "editor.formatOnSave": true, 25 | "editor.formatOnType": true, 26 | "files.trimTrailingWhitespace": true 27 | } 28 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Version of the custom_component** 8 | 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **log** 14 | 15 | ``` 16 | Add your logs here. 17 | ``` -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | change-template: '- #$NUMBER $TITLE @$AUTHOR' 2 | sort-direction: ascending 3 | exclude-labels: 4 | - "release-drafter-ignore" 5 | template: | 6 | ## What’s Changed 7 | 8 | $CHANGES -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | repository: 2 | private: false 3 | has_issues: true 4 | has_projects: false 5 | has_wiki: false 6 | has_downloads: false 7 | default_branch: main 8 | allow_squash_merge: true 9 | allow_merge_commit: false 10 | allow_rebase_merge: false 11 | labels: 12 | - name: "Feature Request" 13 | color: fbca04 14 | - name: "Bug" 15 | color: d73a4a 16 | - name: "Wont Fix" 17 | color: ffffff 18 | - name: "Enhancement" 19 | color: a2eeef 20 | - name: "Documentation" 21 | color: 008672 22 | - name: "Stale" 23 | color: 930191 -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | black: 13 | name: Black 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: 📥 Checkout the repository 17 | uses: actions/checkout@v2 18 | 19 | - name: ⬛ Run Black 20 | uses: psf/black@stable -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release-drafter: 10 | name: Release Drafter 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: 📥 Checkout the repository 14 | uses: actions/checkout@v2 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: ⏭️ Get next version 19 | id: version 20 | run: | 21 | declare -i newpost 22 | latest=$(git describe --tags $(git rev-list --tags --max-count=1)) 23 | latestpre=$(echo "$latest" | awk '{split($0,a,"."); print a[1] "." a[2]}') 24 | datepre=$(date --utc '+%y.%-m') 25 | if [[ "$latestpre" == "$datepre" ]]; then 26 | latestpost=$(echo "$latest" | awk '{split($0,a,"."); print a[3]}') 27 | newpost=$latestpost+1 28 | else 29 | newpost=0 30 | fi 31 | echo Current version: $latest 32 | echo New target version: $datepre.$newpost 33 | echo "::set-output name=version::$datepre.$newpost" 34 | 35 | - name: 🏃 Run Release Drafter 36 | uses: release-drafter/release-drafter@v5 37 | with: 38 | tag: ${{ steps.version.outputs.version }} 39 | name: ${{ steps.version.outputs.version }} 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # This GitHub action workflow is meant to be copyable to any repo that have the same structure. 2 | # - Your integration exist under custom_components/{INTEGRATION_NAME}/[integration files] 3 | # - You are using GitHub releases to publish new versions 4 | # - You have a INTEGRATION_VERSION constant in custom_components/{INTEGRATION_NAME}/const.py 5 | 6 | name: Release Workflow 7 | 8 | on: 9 | release: 10 | types: [published] 11 | 12 | jobs: 13 | release: 14 | name: Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 📥 Checkout the repository 18 | uses: actions/checkout@v2 19 | 20 | - name: 🔢 Get release version 21 | id: version 22 | uses: home-assistant/actions/helpers/version@master 23 | 24 | - name: ℹ️ Get integration information 25 | id: information 26 | run: | 27 | name=$(find custom_components/ -type d -maxdepth 1 | tail -n 1 | cut -d "/" -f2) 28 | echo "::set-output name=name::$name" 29 | 30 | - name: 🖊️ Set version number 31 | run: | 32 | sed -i '/INTEGRATION_VERSION = /c\INTEGRATION_VERSION = "${{ steps.version.outputs.version }}"' \ 33 | "${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}/const.py" 34 | jq '.version = "${{ steps.version.outputs.version }}"' \ 35 | "${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}/manifest.json" > tmp \ 36 | && mv -f tmp "${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}/manifest.json" 37 | 38 | - name: 👀 Validate data 39 | run: | 40 | if ! grep -q 'INTEGRATION_VERSION = "${{ steps.version.outputs.version }}"' ${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}/const.py; then 41 | echo "The version in custom_components/${{ steps.information.outputs.name }}/const.py was not correct" 42 | cat ${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}/const.py | grep INTEGRATION_VERSION 43 | exit 1 44 | fi 45 | manifestversion=$(jq -r '.version' ${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}/manifest.json) 46 | if [ "$manifestversion" != "${{ steps.version.outputs.version }}" ]; then 47 | echo "The version in custom_components/${{ steps.information.outputs.name }}/manifest.json was not correct" 48 | echo "$manifestversion" 49 | exit 1 50 | fi 51 | 52 | - name: 📦 Create zip file for the integration 53 | run: | 54 | cd "${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}" 55 | zip ${{ steps.information.outputs.name }}.zip -r ./ 56 | 57 | - name: 📤 Upload the zip file as a release asset 58 | uses: actions/upload-release-asset@v1 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | with: 62 | upload_url: ${{ github.event.release.upload_url }} 63 | asset_path: "${{ github.workspace }}/custom_components/${{ steps.information.outputs.name }}/${{ steps.information.outputs.name }}.zip" 64 | asset_name: ${{ steps.information.outputs.name }}.zip 65 | asset_content_type: application/zip -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: "0 0 * * *" 12 | 13 | jobs: 14 | validate-hassfest: 15 | runs-on: ubuntu-latest 16 | name: With hassfest 17 | steps: 18 | - name: 📥 Checkout the repository 19 | uses: actions/checkout@v2 20 | 21 | - name: ✅ Hassfest validation 22 | uses: "home-assistant/actions/hassfest@master" 23 | 24 | validate-hacs: 25 | runs-on: ubuntu-latest 26 | name: With HACS Action 27 | steps: 28 | - name: 📥 Checkout the repository 29 | uses: actions/checkout@v2 30 | 31 | - name: ✅ HACS validation 32 | uses: hacs/action@main 33 | with: 34 | category: integration 35 | comment: false 36 | ignore: brands wheels -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant on port 9123", 6 | "type": "shell", 7 | "command": "dc start", 8 | "problemMatcher": [] 9 | }, 10 | { 11 | "label": "Run Home Assistant configuration against /config", 12 | "type": "shell", 13 | "command": "dc check", 14 | "problemMatcher": [] 15 | }, 16 | { 17 | "label": "Upgrade Home Assistant to latest dev", 18 | "type": "shell", 19 | "command": "dc start", 20 | "problemMatcher": [] 21 | }, 22 | { 23 | "label": "Install a spesific version of Home Assistant", 24 | "type": "shell", 25 | "command": "dc set-version", 26 | "problemMatcher": [] 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2021 Joakim Sørensen @ludeeus 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | At this point this integtration is considered deprecated. 2 | 3 | Parts of it can probably be added to core by someone, if its usefull. 4 | 5 | For general info about securing your instance: 6 | - https://www.home-assistant.io/docs/authentication/ 7 | - https://www.home-assistant.io/docs/authentication/multi-factor-auth 8 | - https://www.home-assistant.io/docs/configuration/securing/ 9 | 10 | 11 |
12 | old readme 13 | 14 | 15 | # authenticated 16 | 17 | A platform which allows you to get information successful logins to Home Assistant. 18 | 19 | To get started you should know what to get from this repo, or use [HACS](https://hacs.xyz/). 20 | 21 | **Example configuration.yaml:** 22 | 23 | ```yaml 24 | sensor: 25 | - platform: authenticated 26 | ``` 27 | 28 | **Configuration variables:** 29 | 30 | | key | required | default | description 31 | | --- | --- | --- | --- 32 | | **platform** | yes | | The sensor platform name. 33 | | **enable_notification** | no | `true` | Turn on/off `persistant_notifications` when a new IP is detected, can be `true`/`false`. 34 | | **exclude** | no | | A list of IP addresses you want to exclude. 35 | | **provider** | no | 'ipapi' | The provider you want to use for GEO Lookup, 'ipapi', 'extreme', 'ipvigilante'. 36 | | **log_location** | no | | Full path to the logfile. 37 | 38 | **Sample overview:**\ 39 | ![Sample overview](/img/overview.png) 40 | 41 | If a new IP is detected, it will be added to a `.ip_authenticated.yaml` file in your configdir, with this information: 42 | 43 | ```yaml 44 | 8.8.8.8: 45 | city: Mountain View 46 | country: US 47 | hostname: google-public-dns-a.google.com 48 | last_authenticated: '2018-07-26 09:27:01' 49 | previous_authenticated_time: '2018-07-26 09:27:01' 50 | region: california 51 | ``` 52 | 53 | If not disabled, you will also be presented with a `persistent_notification` about the event:\ 54 | ![notification](/img/persistant_notification.png) 55 | 56 | ## Debug logging 57 | 58 | In your `configuration.yaml` 59 | 60 | ```yaml 61 | logger: 62 | default: warn 63 | logs: 64 | custom_components.sensor.authenticated: debug 65 | ``` 66 | 67 | *** 68 | 69 | [buymeacoffee.com](https://www.buymeacoffee.com/ludeeus) 70 | 71 | 72 |
73 | -------------------------------------------------------------------------------- /custom_components/authenticated/__init__.py: -------------------------------------------------------------------------------- 1 | """The authenticated component.""" 2 | 3 | 4 | class AuthenticatedBaseException(Exception): 5 | """Base exception for Authenticated.""" 6 | -------------------------------------------------------------------------------- /custom_components/authenticated/const.py: -------------------------------------------------------------------------------- 1 | """Constants for authenticated.""" 2 | 3 | DOMAIN = "authenticated" 4 | INTEGRATION_VERSION = "main" 5 | ISSUE_URL = "https://github.com/custom-components/authenticated/issues" 6 | 7 | STARTUP = f""" 8 | ------------------------------------------------------------------- 9 | {DOMAIN} 10 | Version: {INTEGRATION_VERSION} 11 | This is a custom component 12 | If you have any issues with this you need to open an issue here: 13 | https://github.com/custom-components/authenticated/issues 14 | ------------------------------------------------------------------- 15 | """ 16 | 17 | 18 | CONF_NOTIFY = "enable_notification" 19 | CONF_EXCLUDE = "exclude" 20 | CONF_EXCLUDE_CLIENTS = "exclude_clients" 21 | CONF_PROVIDER = "provider" 22 | CONF_LOG_LOCATION = "log_location" 23 | 24 | OUTFILE = ".ip_authenticated.yaml" 25 | -------------------------------------------------------------------------------- /custom_components/authenticated/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "authenticated", 3 | "name": "Authenticated", 4 | "version": "0.0.0", 5 | "iot_class": "local_polling", 6 | "documentation": "https://github.com/custom-components/authenticated", 7 | "issue_tracker": "https://github.com/custom-components/authenticated/issues", 8 | "codeowners": [ 9 | "@ludeeus" 10 | ] 11 | } -------------------------------------------------------------------------------- /custom_components/authenticated/providers.py: -------------------------------------------------------------------------------- 1 | """Providers""" 2 | import logging 3 | import requests 4 | from . import AuthenticatedBaseException 5 | 6 | _LOGGER = logging.getLogger(__name__) 7 | 8 | PROVIDERS = {} 9 | 10 | 11 | def register_provider(classname): 12 | """Decorator used to register providers.""" 13 | PROVIDERS[classname.name] = classname 14 | return classname 15 | 16 | 17 | class GeoProvider: 18 | """GeoProvider class.""" 19 | 20 | url = None 21 | 22 | def __init__(self, ipaddr): 23 | """Initialize.""" 24 | self.result = {} 25 | self.ipaddr = ipaddr 26 | 27 | @property 28 | def country(self): 29 | """Return country name or None.""" 30 | return self.result.get("country") 31 | 32 | @property 33 | def region(self): 34 | """Return region name or None.""" 35 | return self.result.get("region") 36 | 37 | @property 38 | def city(self): 39 | """Return city name or None.""" 40 | return self.result.get("city") 41 | 42 | @property 43 | def computed_result(self): 44 | """Return the computed result.""" 45 | if self.result is not None: 46 | return {"country": self.country, "region": self.region, "city": self.city} 47 | return None 48 | 49 | def update_geo_info(self): 50 | """Update Geo Information.""" 51 | self.result = {} 52 | try: 53 | api = self.url.format(self.ipaddr) 54 | header = {"user-agent": "Home Assistant/Python"} 55 | data = requests.get(api, headers=header, timeout=5).json() 56 | 57 | if data.get("error"): 58 | if data.get("reason") == "RateLimited": 59 | raise AuthenticatedBaseException( 60 | "RatelimitError, try a different provider." 61 | ) 62 | 63 | elif data.get("status", "success") == "error": 64 | return 65 | 66 | elif data.get("reserved"): 67 | return 68 | 69 | elif data.get("status", "success") == "fail": 70 | raise AuthenticatedBaseException( 71 | "[{}] - {}".format( 72 | self.ipaddr, data.get("message", "Unknown error.") 73 | ) 74 | ) 75 | 76 | self.result = data 77 | self.parse_data() 78 | except AuthenticatedBaseException as exception: 79 | _LOGGER.error(exception) 80 | except requests.exceptions.ConnectionError: 81 | pass 82 | 83 | def parse_data(self): 84 | """Parse data from geoprovider.""" 85 | self.result = self.result 86 | 87 | 88 | @register_provider 89 | class IPApi(GeoProvider): 90 | """IPApi class.""" 91 | 92 | url = "https://ipapi.co/{}/json" 93 | name = "ipapi" 94 | 95 | @property 96 | def country(self): 97 | """Return country name or None.""" 98 | return self.result.get("country_name") 99 | 100 | 101 | @register_provider 102 | class ExtremeIPLookup(GeoProvider): 103 | """IPApi class.""" 104 | 105 | url = "https://extreme-ip-lookup.com/json/{}" 106 | name = "extreme" 107 | 108 | 109 | @register_provider 110 | class IPVigilante(GeoProvider): 111 | """IPVigilante class.""" 112 | 113 | url = "https://ipvigilante.com/json/{}" 114 | name = "ipvigilante" 115 | 116 | def parse_data(self): 117 | """Parse data from geoprovider.""" 118 | self.result = self.result.get("data", {}) 119 | 120 | @property 121 | def country(self): 122 | """Return country name or None.""" 123 | return self.result.get("country_name") 124 | 125 | @property 126 | def region(self): 127 | """Return region name or None.""" 128 | return self.result.get("subdivision_1_name") 129 | 130 | @property 131 | def city(self): 132 | """Return city name or None.""" 133 | return self.result.get("city_name") 134 | -------------------------------------------------------------------------------- /custom_components/authenticated/sensor.py: -------------------------------------------------------------------------------- 1 | """ 2 | A platform which allows you to get information 3 | about successful logins to Home Assistant. 4 | For more details about this component, please refer to the documentation at 5 | https://github.com/custom-components/authenticated 6 | """ 7 | from datetime import datetime, timedelta 8 | import json 9 | import logging 10 | import os 11 | from ipaddress import ip_address as ValidateIP, ip_network 12 | import socket 13 | import voluptuous as vol 14 | import yaml 15 | 16 | import homeassistant.helpers.config_validation as cv 17 | from homeassistant.components.sensor import PLATFORM_SCHEMA 18 | from homeassistant.helpers.entity import Entity 19 | 20 | from .providers import PROVIDERS 21 | from .const import ( 22 | OUTFILE, 23 | CONF_NOTIFY, 24 | CONF_EXCLUDE, 25 | CONF_EXCLUDE_CLIENTS, 26 | CONF_PROVIDER, 27 | CONF_LOG_LOCATION, 28 | STARTUP, 29 | ) 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | ATTR_HOSTNAME = "hostname" 34 | ATTR_COUNTRY = "country" 35 | ATTR_REGION = "region" 36 | ATTR_CITY = "city" 37 | ATTR_NEW_IP = "new_ip" 38 | ATTR_LAST_AUTHENTICATE_TIME = "last_authenticated_time" 39 | ATTR_PREVIOUS_AUTHENTICATE_TIME = "previous_authenticated_time" 40 | ATTR_USER = "username" 41 | 42 | SCAN_INTERVAL = timedelta(minutes=1) 43 | 44 | PLATFORM_NAME = "authenticated" 45 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 46 | { 47 | vol.Optional(CONF_PROVIDER, default="ipapi"): vol.In( 48 | ["ipapi", "extreme", "ipvigilante"] 49 | ), 50 | vol.Optional(CONF_LOG_LOCATION, default=""): cv.string, 51 | vol.Optional(CONF_NOTIFY, default=True): cv.boolean, 52 | vol.Optional(CONF_EXCLUDE, default=[]): vol.All(cv.ensure_list, [cv.string]), 53 | vol.Optional(CONF_EXCLUDE_CLIENTS, default=[]): vol.All( 54 | cv.ensure_list, [cv.string] 55 | ), 56 | } 57 | ) 58 | 59 | 60 | def humanize_time(timestring): 61 | """Convert time.""" 62 | return datetime.strptime(timestring[:19], "%Y-%m-%dT%H:%M:%S") 63 | 64 | 65 | def setup_platform(hass, config, add_devices, discovery_info=None): 66 | # Print startup message 67 | _LOGGER.info(STARTUP) 68 | 69 | """Create the sensor""" 70 | notify = config.get(CONF_NOTIFY) 71 | exclude = config.get(CONF_EXCLUDE) 72 | exclude_clients = config.get(CONF_EXCLUDE_CLIENTS) 73 | hass.data[PLATFORM_NAME] = {} 74 | 75 | if not load_authentications( 76 | hass.config.path(".storage/auth"), exclude, exclude_clients 77 | ): 78 | return False 79 | 80 | out = str(hass.config.path(OUTFILE)) 81 | 82 | sensor = AuthenticatedSensor( 83 | hass, notify, out, exclude, exclude_clients, config[CONF_PROVIDER] 84 | ) 85 | sensor.initial_run() 86 | 87 | add_devices([sensor], True) 88 | 89 | 90 | class AuthenticatedSensor(Entity): 91 | """Representation of a Sensor.""" 92 | 93 | def __init__(self, hass, notify, out, exclude, exclude_clients, provider): 94 | """Initialize the sensor.""" 95 | self.hass = hass 96 | self._state = None 97 | self.provider = provider 98 | self.stored = {} 99 | self.last_ip = None 100 | self.exclude = exclude 101 | self.exclude_clients = exclude_clients 102 | self.notify = notify 103 | self.out = out 104 | 105 | def initial_run(self): 106 | """Run this at startup to initialize the platform data.""" 107 | users, tokens = load_authentications( 108 | self.hass.config.path(".storage/auth"), self.exclude, self.exclude_clients 109 | ) 110 | 111 | if os.path.isfile(self.out): 112 | self.stored = get_outfile_content(self.out) 113 | else: 114 | _LOGGER.debug("File has not been created, no data pressent.") 115 | 116 | for access in tokens: 117 | 118 | try: 119 | ValidateIP(access) 120 | except ValueError: 121 | continue 122 | 123 | accessdata = AuthenticatedData(access, tokens[access]) 124 | 125 | if accessdata.ipaddr in self.stored: 126 | store = AuthenticatedData(accessdata.ipaddr, self.stored[access]) 127 | accessdata.ipaddr = access 128 | 129 | if store.user_id is not None: 130 | accessdata.user_id = store.user_id 131 | 132 | if store.hostname is not None: 133 | accessdata.hostname = store.hostname 134 | 135 | if store.country is not None: 136 | accessdata.country = store.country 137 | 138 | if store.region is not None: 139 | accessdata.region = store.region 140 | 141 | if store.city is not None: 142 | accessdata.city = store.city 143 | 144 | if store.last_access is not None: 145 | accessdata.last_access = store.last_access 146 | elif store.attributes.get("last_authenticated") is not None: 147 | accessdata.last_access = store.attributes["last_authenticated"] 148 | elif store.attributes.get("last_used_at") is not None: 149 | accessdata.last_access = store.attributes["last_used_at"] 150 | 151 | if store.prev_access is not None: 152 | accessdata.prev_access = store.prev_access 153 | elif store.attributes.get("previous_authenticated_time") is not None: 154 | accessdata.prev_access = store.attributes[ 155 | "previous_authenticated_time" 156 | ] 157 | elif store.attributes.get("prev_used_at") is not None: 158 | accessdata.prev_access = store.attributes["prev_used_at"] 159 | 160 | ipaddress = IPData(accessdata, users, self.provider, False) 161 | if accessdata.ipaddr not in self.stored: 162 | ipaddress.lookup() 163 | self.hass.data[PLATFORM_NAME][access] = ipaddress 164 | self.write_to_file() 165 | 166 | def update(self): 167 | """Method to update sensor value""" 168 | updated = False 169 | users, tokens = load_authentications( 170 | self.hass.config.path(".storage/auth"), self.exclude, self.exclude_clients 171 | ) 172 | _LOGGER.debug("Users %s", users) 173 | _LOGGER.debug("Access %s", tokens) 174 | for access in tokens: 175 | try: 176 | ValidateIP(access) 177 | except ValueError: 178 | continue 179 | 180 | if access in self.hass.data[PLATFORM_NAME]: 181 | ipaddress = self.hass.data[PLATFORM_NAME][access] 182 | 183 | try: 184 | new = humanize_time(tokens[access]["last_used_at"]) 185 | stored = humanize_time(ipaddress.last_used_at) 186 | 187 | if new == stored: 188 | continue 189 | if new is None or stored is None: 190 | continue 191 | elif new > stored: 192 | updated = True 193 | _LOGGER.info("New successful login from known IP (%s)", access) 194 | ipaddress.prev_used_at = ipaddress.last_used_at 195 | ipaddress.last_used_at = tokens[access]["last_used_at"] 196 | except Exception: # pylint: disable=broad-except 197 | pass 198 | else: 199 | updated = True 200 | _LOGGER.warning("New successful login from unknown IP (%s)", access) 201 | accessdata = AuthenticatedData(access, tokens[access]) 202 | ipaddress = IPData(accessdata, users, self.provider) 203 | ipaddress.lookup() 204 | 205 | if ipaddress.hostname is None: 206 | ipaddress.hostname = get_hostname(ipaddress.ip_address) 207 | 208 | if ipaddress.new_ip: 209 | if self.notify: 210 | ipaddress.notify(self.hass) 211 | ipaddress.new_ip = False 212 | 213 | self.hass.data[PLATFORM_NAME][access] = ipaddress 214 | 215 | for ipaddr in sorted( 216 | tokens, key=lambda x: tokens[x]["last_used_at"], reverse=True 217 | ): 218 | self.last_ip = self.hass.data[PLATFORM_NAME][ipaddr] 219 | break 220 | if self.last_ip is not None: 221 | self._state = self.last_ip.ip_address 222 | if updated: 223 | self.write_to_file() 224 | 225 | @property 226 | def name(self): 227 | """Return the name of the sensor.""" 228 | return "Last successful authentication" 229 | 230 | @property 231 | def state(self): 232 | """Return the state of the sensor.""" 233 | return self._state 234 | 235 | @property 236 | def icon(self): 237 | """Return the icon of the sensor.""" 238 | return "mdi:lock-alert" 239 | 240 | @property 241 | def extra_state_attributes(self): 242 | """Return attributes for the sensor.""" 243 | if self.last_ip is None: 244 | return None 245 | return { 246 | ATTR_HOSTNAME: self.last_ip.hostname, 247 | ATTR_COUNTRY: self.last_ip.country, 248 | ATTR_REGION: self.last_ip.region, 249 | ATTR_CITY: self.last_ip.city, 250 | ATTR_USER: self.last_ip.username, 251 | ATTR_NEW_IP: self.last_ip.new_ip, 252 | ATTR_LAST_AUTHENTICATE_TIME: self.last_ip.last_used_at, 253 | ATTR_PREVIOUS_AUTHENTICATE_TIME: self.last_ip.prev_used_at, 254 | } 255 | 256 | def write_to_file(self): 257 | """Write data to file.""" 258 | if os.path.exists(self.out): 259 | info = get_outfile_content(self.out) 260 | else: 261 | info = {} 262 | 263 | for known in self.hass.data[PLATFORM_NAME]: 264 | known = self.hass.data[PLATFORM_NAME][known] 265 | info[known.ip_address] = { 266 | "user_id": known.user_id, 267 | "username": known.username, 268 | "last_used_at": known.last_used_at, 269 | "prev_used_at": known.prev_used_at, 270 | "country": known.country, 271 | "hostname": known.hostname, 272 | "region": known.region, 273 | "city": known.city, 274 | } 275 | with open(self.out, "w") as out_file: 276 | yaml.dump(info, out_file, default_flow_style=False, explicit_start=True) 277 | 278 | 279 | def get_outfile_content(file): 280 | """Get the content of the outfile""" 281 | with open(file) as out_file: 282 | content = yaml.load(out_file, Loader=yaml.FullLoader) 283 | out_file.close() 284 | 285 | if isinstance(content, dict): 286 | return content 287 | return {} 288 | 289 | 290 | def get_geo_data(ip_address, provider): 291 | """Get geo data for an IP""" 292 | result = {"result": False, "data": "none"} 293 | geo_data = PROVIDERS[provider](ip_address) 294 | geo_data.update_geo_info() 295 | 296 | if geo_data.computed_result is not None: 297 | result = {"result": True, "data": geo_data.computed_result} 298 | 299 | return result 300 | 301 | 302 | def get_hostname(ip_address): 303 | """Return hostname for an IP""" 304 | hostname = None 305 | try: 306 | hostname = socket.getfqdn(ip_address) 307 | except Exception: 308 | pass 309 | return hostname 310 | 311 | 312 | def load_authentications(authfile, exclude, exclude_clients): 313 | """Load info from auth file.""" 314 | if not os.path.exists(authfile): 315 | _LOGGER.critical("File is missing %s", authfile) 316 | return False 317 | with open(authfile, "r") as authfile: 318 | auth = json.loads(authfile.read()) 319 | 320 | users = {} 321 | for user in auth["data"]["users"]: 322 | users[user["id"]] = user["name"] 323 | 324 | tokens = auth["data"]["refresh_tokens"] 325 | tokens_cleaned = {} 326 | 327 | for token in tokens: 328 | try: 329 | for excludeaddress in exclude: 330 | if ValidateIP(token["last_used_ip"]) in ip_network( 331 | excludeaddress, False 332 | ): 333 | raise Exception("IP in excluded address configuration") 334 | if token["client_id"] in exclude_clients: 335 | raise Exception("Client in excluded clients configuration") 336 | if token.get("last_used_at") is None: 337 | continue 338 | if token["last_used_ip"] in tokens_cleaned: 339 | if ( 340 | token["last_used_at"] 341 | > tokens_cleaned[token["last_used_ip"]]["last_used_at"] 342 | ): 343 | tokens_cleaned[token["last_used_ip"]]["last_used_at"] = token[ 344 | "last_used_at" 345 | ] 346 | tokens_cleaned[token["last_used_ip"]]["user_id"] = token["user_id"] 347 | else: 348 | tokens_cleaned[token["last_used_ip"]] = {} 349 | tokens_cleaned[token["last_used_ip"]]["last_used_at"] = token[ 350 | "last_used_at" 351 | ] 352 | tokens_cleaned[token["last_used_ip"]]["user_id"] = token["user_id"] 353 | except Exception: # Gotta Catch 'Em All 354 | pass 355 | 356 | return users, tokens_cleaned 357 | 358 | 359 | class AuthenticatedData: 360 | """Data class for authenticated values.""" 361 | 362 | def __init__(self, ipaddr, attributes): 363 | """Initialize.""" 364 | self.ipaddr = ipaddr 365 | self.attributes = attributes 366 | self.last_access = attributes.get("last_used_at") 367 | self.prev_access = attributes.get("prev_used_at") 368 | self.country = attributes.get("country") 369 | self.region = attributes.get("region") 370 | self.city = attributes.get("city") 371 | self.user_id = attributes.get("user_id") 372 | self.hostname = attributes.get("hostname") 373 | 374 | 375 | class IPData: 376 | """IP Address class.""" 377 | 378 | def __init__(self, access_data, users, provider, new=True): 379 | self.all_users = users 380 | self.provider = provider 381 | self.ip_address = access_data.ipaddr 382 | self.last_used_at = access_data.last_access 383 | self.prev_used_at = access_data.prev_access 384 | self.user_id = access_data.user_id 385 | self.hostname = access_data.hostname 386 | self.city = access_data.city 387 | self.region = access_data.region 388 | self.country = access_data.country 389 | self.new_ip = new 390 | 391 | @property 392 | def username(self): 393 | """Return the username used for the login.""" 394 | if self.user_id is None: 395 | return "Unknown" 396 | elif self.user_id in self.all_users: 397 | return self.all_users[self.user_id] 398 | return "Unknown" 399 | 400 | def lookup(self): 401 | """Look up data for the IP address.""" 402 | geo = get_geo_data(self.ip_address, self.provider) 403 | if geo["result"]: 404 | self.country = geo.get("data", {}).get("country") 405 | self.region = geo.get("data", {}).get("region") 406 | self.city = geo.get("data", {}).get("city") 407 | 408 | def notify(self, hass): 409 | """Create persistant notification.""" 410 | notify = hass.components.persistent_notification.create 411 | if self.country is not None: 412 | country = "**Country:** {}".format(self.country) 413 | else: 414 | country = "" 415 | if self.hostname is not None: 416 | hostname = "**Hostname:** {}".format(self.hostname) 417 | else: 418 | hostname = "" 419 | if self.region is not None: 420 | region = "**Region:** {}".format(self.region) 421 | else: 422 | region = "" 423 | if self.city is not None: 424 | city = "**City:** {}".format(self.city) 425 | else: 426 | city = "" 427 | if self.last_used_at is not None: 428 | last_used_at = "**Login time:** {}".format(self.last_used_at[:19]) 429 | else: 430 | last_used_at = "" 431 | message = """ 432 | **IP Address:** {} 433 | **Username:** {} 434 | {} 435 | {} 436 | {} 437 | {} 438 | {} 439 | """.format( 440 | self.ip_address, 441 | self.username, 442 | country, 443 | hostname, 444 | region, 445 | city, 446 | last_used_at.replace("T", " "), 447 | ) 448 | notify(message, title="New successful login", notification_id=self.ip_address) 449 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Authenticated", 3 | "zip_release": true, 4 | "hide_default_branch": true, 5 | "filename": "authenticated.zip", 6 | "domain": "authenticated" 7 | } -------------------------------------------------------------------------------- /img/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-components/authenticated/de6f80225f59185a193aeeeae1279b2777eee372/img/overview.png -------------------------------------------------------------------------------- /img/persistant_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/custom-components/authenticated/de6f80225f59185a193aeeeae1279b2777eee372/img/persistant_notification.png -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | **Sample overview:** 2 | 3 | ![Sample overview](https://github.com/custom-components/authenticated/blob/master/img/overview.png) 4 | 5 | If a new IP is detected, it will be added to a `.ip_authenticated.yaml` file in your configdir, with this information: 6 | 7 | ```yaml 8 | 8.8.8.8: 9 | city: Mountain View 10 | country: US 11 | hostname: google-public-dns-a.google.com 12 | last_authenticated: '2018-07-26 09:27:01' 13 | previous_authenticated_time: '2018-07-26 09:27:01' 14 | region: california 15 | ``` 16 | 17 | If not disabled, you will also be presented with a `persistent_notification` about the event: 18 | 19 | ![notification](https://github.com/custom-components/authenticated/raw/master/img/persistant_notification.png) --------------------------------------------------------------------------------