diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..844d8b8
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,81 @@
+name: Deploy to Cloudflare Workers
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ name: Deploy
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: |
+ frontend/package.json
+ backend/package.json
+
+ # 关键步骤:Wrangler 的 build 命令会调用前端构建,先进入 frontend 目录安装依赖,否则构建会失败。
+ - name: Install Frontend Dependencies
+ run: |
+ cd frontend
+ npm install
+
+ # 安装后端依赖 (包含 wrangler 等)
+ - name: Install Backend Dependencies
+ run: |
+ cd backend
+ npm install
+
+ # 动态注入 D1 数据库ID 和 数据库名称
+ - name: Inject D1 Database ID
+ env:
+ CLOUDFLARE_D1_DATABASE_ID: ${{ secrets.CLOUDFLARE_D1_DATABASE_ID }}
+ CLOUDFLARE_D1_DATABASE_NAME: ${{ secrets.CLOUDFLARE_D1_DATABASE_NAME || '2fauth-db' }}
+ run: |
+ # 仅替换第一个匹配到的 database_id (对应生产环境配置),保留 dev 环境配置
+ sed -i "0,/\"database_id\":/s/\"database_id\": \".*\"/\"database_id\": \"$CLOUDFLARE_D1_DATABASE_ID\"/" backend/wrangler.jsonc
+ sed -i "0,/\"database_name\":/s/\"database_name\": \".*\"/\"database_name\": \"$CLOUDFLARE_D1_DATABASE_NAME\"/" backend/wrangler.jsonc
+
+ # 初始化或更新数据库结构 (执行 schema.sql)
+ - name: Update Database Schema
+ uses: cloudflare/wrangler-action@v3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ workingDirectory: 'backend'
+ command: d1 execute ${{ secrets.CLOUDFLARE_D1_DATABASE_NAME || '2fauth-db' }} --remote --file=schema.sql
+
+ # 使用官方 Action 进行部署
+ - name: Deploy to Cloudflare Workers
+ uses: cloudflare/wrangler-action@v3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ workingDirectory: 'backend'
+ # 保持与 package.json 中一致的参数,开启压缩
+ command: deploy --minify
+ secrets: |
+ OAUTH_CLIENT_ID
+ OAUTH_CLIENT_SECRET
+ OAUTH_BASE_URL
+ OAUTH_ID
+ OAUTH_REDIRECT_URI
+ ENCRYPTION_KEY
+ JWT_SECRET
+ env:
+ OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }}
+ OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}
+ OAUTH_BASE_URL: ${{ secrets.OAUTH_BASE_URL || 'https://github.com' }}
+ OAUTH_ID: ${{ secrets.OAUTH_ID }}
+ OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
+ ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
+ JWT_SECRET: ${{ secrets.JWT_SECRET }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c281286
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+logs
+*.log
+.DS_Store
+
+.secrets
+
+.vscode/*
+.devcontainer/*
+
+backend/.dev.vars
+backend/.wrangler
+backend/README.md
+backend/node_modules
+backend/package-lock.json
+
+frontend/.vscode
+frontend/node_modules
+frontend/package-lock.json
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0ad25db
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e598c02
--- /dev/null
+++ b/README.md
@@ -0,0 +1,135 @@
+# 2FAuth Worker - Serverless 2FA Manager
+
+**2FAuth Worker** 是一个基于 Cloudflare Workers + D1 (SQLite) + Vue 3 构建的轻量级、安全、无服务器的二步验证(2FA/TOTP)管理工具。
+
+它允许你完全掌控自己的 2FA 数据,支持多端同步、WebDAV 备份、加密导出,并且完全免费部署在 Cloudflare 的边缘网络上。
+
+> **免责声明**
+> 本项目代码 `100%` 由 `Gemini 3.5 Pro` 生成,作者不对任何数据丢失负责。
+
+## ✨ 功能特性
+
+- ☁️ **Serverless 架构**:后端运行在 Cloudflare Workers,数据库使用 D1,无需购买服务器。
+- 🔒 **安全隐私**:数据存储在你的私有 D1 数据库中,支持 AES-GCM 高强度加密导出。
+- 📱 **多端适配**:响应式设计,完美支持 PC 和移动端访问。
+- 📷 **扫码添加**:支持调用摄像头扫描二维码或上传图片识别添加账号。
+- 🔄 **数据同步**:支持 WebDAV 自动备份,数据永不丢失。
+- 📂 **导入导出**:支持从 Google Authenticator (导出文本), 2FAS, 或标准 JSON 格式导入数据。
+- 🔐 **GitHub 登录**:集成 GitHub OAuth 安全登录。
+
+## 🛠️ 技术栈
+
+- **Frontend**: Vue 3, Vite, Element Plus, Pinia (State Management via Reactive)
+- **Backend**: Cloudflare Workers, Hono (Web Framework)
+- **Database**: Cloudflare D1 (Serverless SQLite)
+- **Deployment**: GitHub Actions
+
+## 🚀 部署指南 (Deployment)
+
+本项目设计为通过 GitHub Actions 自动部署到 Cloudflare。
+
+### 1. 准备工作
+
+1. 注册一个 [Cloudflare](https://dash.cloudflare.com/) 账号。
+2. Fork 本仓库到你的 GitHub 账户。
+
+### 2. 配置 Cloudflare D1 数据库
+
+1. 登录 Cloudflare Dashboard。
+2. 进入左侧菜单的 **存储和数据库** -> **D1 SQL 数据库**。
+3. 点击 **+ 创建数据库** 创建新数据库,命名为 **2fauth-db**,点击 **创建** 确认。
+4. 创建完成后,在数据库详情页找到 **Database ID** 并复制。
+5. 请妥善保存这个 ID,稍后我们需要将其配置到 GitHub Secrets (`CLOUDFLARE_D1_DATABASE_ID`) 中。
+
+### 3. 配置 GitHub OAuth
+
+1. 访问 GitHub Developer Settings -> **OAuth Apps** -> **New OAuth App**。
+2. 填写信息:
+ - **Application Name**: 2FAuth (或任意名称)
+ - **Homepage URL**: `https://你的Worker子域名.workers.dev` (部署后可修改为Worker绑定的自定义域名)
+ - **Authorization callback URL**: `https://你的Worker子域名.workers.dev/api/oauth/callback` (部署后可修改为Worker绑定的自定义域名)
+3. 创建后,保存 `Client ID` 并生成一个新的 `Client Secret`。
+
+### 4. 配置 GitHub Secrets
+
+在你的 GitHub 仓库中,进入 **Settings** -> **Secrets and variables** -> **Actions** -> **New repository secret**,添加以下变量:
+
+| Secret Name | 说明 | 示例值 |
+| :--- | :--- | :--- |
+| `CLOUDFLARE_API_TOKEN` | Cloudflare API 令牌 (需有 Workers 和 D1 读写权限) | `r4_...` |
+| `CLOUDFLARE_D1_DATABASE_ID` | 你的 D1 数据库 ID (步骤 2 中生成的) | `xxxxxxxx-xxxx...` |
+| `CLOUDFLARE_D1_DATABASE_NAME` | 你的 D1 数据库 名称 (步骤 2 中生成的) | 默认`2fauth-db` |
+| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare 账户 ID (在 Dash 面板右下角) | `a1b2...` |
+| `OAUTH_CLIENT_ID` | GitHub OAuth Client ID | `Ov23...` |
+| `OAUTH_CLIENT_SECRET` | GitHub OAuth Client Secret | `a1b2...` |
+| `OAUTH_BASE_URL` | GitHub API 基础地址 | 默认`https://github.com` |
+| `OAUTH_ID` | 允许登录的 GitHub 用户名 (白名单机制) | `your_github_username` |
+| `OAUTH_REDIRECT_URI` | 你的 Worker 回调地址 | `https://xxx.workers.dev/oauth/callback` |
+| `ENCRYPTION_KEY` | 用于加密敏感数据的密钥 (32位随机字符串) | `openssl rand -hex 16` 生成 |
+| `JWT_SECRET` | 用于签发登录 Token 的密钥 (随机字符串) | `random_string_here` |
+
+### 5. 自动部署
+
+1. 进入 GitHub 仓库的 **Actions** 页面。
+2. 手动触发 `Deploy to Cloudflare Workers` 工作流,或者直接 Push 代码到 `main` 分支。
+3. 等待工作流执行完毕。
+4. 部署成功后,访问你的 Worker URL 即可使用!
+
+> **注意**:首次部署时,GitHub Action 会自动执行 `schema.sql` 初始化数据库表结构。
+
+---
+
+## 💻 本地开发 (Local Development)
+
+### 环境要求
+- Node.js 20+
+- npm
+
+### 1. 克隆项目
+```bash
+git clone https://github.com/nap0o/2fauth-worker.git
+cd 2fauth-worker
+```
+
+### 2. 安装依赖
+```bash
+# 安装前端依赖
+cd frontend
+npm install
+
+# 安装后端依赖
+cd ../backend
+npm install
+```
+
+### 3. 配置本地环境变量
+在 `backend` 目录下创建 `.dev.vars` 文件,填入开发所需的 Secrets:
+```ini
+OAUTH_CLIENT_ID="你的测试AppID"
+OAUTH_CLIENT_SECRET="你的测试AppSecret"
+OAUTH_REDIRECT_URI="http://localhost:5173/oauth/callback"
+OAUTH_BASE_URL="https://github.com"
+OAUTH_ID="你的GitHub用户名"
+ENCRYPTION_KEY="本地测试用的32位密钥"
+JWT_SECRET="本地测试用的JWT密钥"
+```
+
+### 4. 初始化本地数据库
+```bash
+cd backend
+npx wrangler d1 execute 2fauth-db-dev --local --env dev --file=schema.sql
+```
+
+### 5. 启动开发服务器
+
+**终端 1 (后端):**
+```bash
+cd backend
+npm run dev
+```
+
+**终端 2 (前端):**
+```bash
+cd frontend
+npm run dev
+```
\ No newline at end of file
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..e319e06
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,33 @@
+# prod
+dist/
+
+# dev
+.yarn/
+!.yarn/releases
+.vscode/*
+!.vscode/launch.json
+!.vscode/*.code-snippets
+.idea/workspace.xml
+.idea/usage.statistics.xml
+.idea/shelf
+
+# deps
+node_modules/
+.wrangler
+
+# env
+.env
+.env.production
+.dev.vars
+
+# logs
+logs/
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+# misc
+.DS_Store
diff --git a/backend/package.json b/backend/package.json
new file mode 100644
index 0000000..0bd2660
--- /dev/null
+++ b/backend/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "backend",
+ "type": "module",
+ "scripts": {
+ "dev": "wrangler dev src/index.ts --env dev --host 0.0.0.0 --port 8787",
+ "deploy": "wrangler deploy --minify src/index.ts"
+ },
+ "dependencies": {
+ "hono": "^4.11.10",
+ "webdav": "^5.9.0"
+ },
+ "devDependencies": {
+ "wrangler": "^3.0.0"
+ }
+}
diff --git a/backend/schema.sql b/backend/schema.sql
new file mode 100644
index 0000000..162ed95
--- /dev/null
+++ b/backend/schema.sql
@@ -0,0 +1,28 @@
+-- 账号表:每一行存储一个 2FA 凭据
+CREATE TABLE IF NOT EXISTS accounts (
+ id TEXT PRIMARY KEY,
+ service TEXT NOT NULL,
+ account TEXT NOT NULL,
+ category TEXT,
+ secret TEXT NOT NULL, -- 存储加密后的 JSON 字符串 {encrypted, iv, salt}
+ digits INTEGER DEFAULT 6,
+ period INTEGER DEFAULT 30,
+ created_at INTEGER,
+ created_by TEXT,
+ updated_at INTEGER,
+ updated_by TEXT
+);
+
+-- WebDAV 配置表
+CREATE TABLE IF NOT EXISTS webdav_configs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ url TEXT NOT NULL,
+ username TEXT NOT NULL,
+ password TEXT NOT NULL, -- 存储加密后的 JSON 字符串
+ save_dir TEXT,
+ updated_at INTEGER
+);
+
+-- 创建索引以加速查询
+CREATE INDEX IF NOT EXISTS idx_accounts_service ON accounts(service);
+CREATE INDEX IF NOT EXISTS idx_accounts_created_at ON accounts(created_at DESC);
\ No newline at end of file
diff --git a/backend/src/config.ts b/backend/src/config.ts
new file mode 100644
index 0000000..5ee2fbb
--- /dev/null
+++ b/backend/src/config.ts
@@ -0,0 +1,34 @@
+export const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+
+export const SECURITY_CONFIG = {
+ MAX_LOGIN_ATTEMPTS: 5,
+ LOCKOUT_TIME: 15 * 60 * 1000,
+ JWT_EXPIRY: 2 * 60 * 60, // 2小时
+ MAX_INPUT_LENGTH: 100,
+ MIN_EXPORT_PASSWORD_LENGTH: 12,
+ MAX_OAUTH_ATTEMPTS: 3,
+ OAUTH_LOCKOUT_TIME: 10 * 60 * 1000,
+ MAX_FILE_SIZE: 10 * 1024 * 1024,
+};
+
+// Cloudflare Workers 环境变量类型定义
+export type EnvBindings = {
+ DB: D1Database;
+ OAUTH_BASE_URL: string;
+ OAUTH_CLIENT_ID: string;
+ OAUTH_CLIENT_SECRET: string;
+ OAUTH_REDIRECT_URI: string;
+ OAUTH_ID: string;
+ JWT_SECRET: string;
+ ENCRYPTION_KEY: string;
+};
+
+// 自定义错误类
+export class AppError extends Error {
+ statusCode: number;
+ constructor(message: string, statusCode = 500) {
+ super(message);
+ this.name = 'AppError';
+ this.statusCode = statusCode;
+ }
+}
\ No newline at end of file
diff --git a/backend/src/index.ts b/backend/src/index.ts
new file mode 100644
index 0000000..df0693e
--- /dev/null
+++ b/backend/src/index.ts
@@ -0,0 +1,68 @@
+import { Hono } from 'hono';
+import { cors } from 'hono/cors';
+import { logger } from 'hono/logger';
+import { EnvBindings } from './config';
+
+// 稍后我们会在这里引入拆分好的路由模块
+import authRoutes from './routes/auth';
+import accountsRoutes from './routes/accounts';
+import webdavRoutes from './routes/webdav';
+
+// 扩展 EnvBindings 以包含 ASSETS (Cloudflare Pages/Workers Assets)
+type Bindings = EnvBindings & { ASSETS: { fetch: (req: Request) => Promise } };
+
+// 初始化 Hono 应用,并绑定 Cloudflare 的环境变量类型
+const app = new Hono<{ Bindings: Bindings }>();
+
+// 1. 全局中间件
+app.use('*', logger()); // 自动打印请求日志
+app.use('/api/*', cors({
+ origin: '*', // 开发环境允许跨域,生产环境可配置具体域名
+ allowHeaders: ['Content-Type', 'Authorization'],
+ allowMethods: ['POST', 'GET', 'OPTIONS', 'PUT', 'DELETE'],
+ maxAge: 86400,
+}));
+
+// 2. 健康检查接口 (用于测试后端是否正常启动)
+app.get('/api', (c) => c.text('🔐 2FA Secure Manager API is running!'));
+
+// 3. 挂载子路由
+app.route('/api/oauth', authRoutes);
+app.route('/api/accounts', accountsRoutes);
+app.route('/api/webdav', webdavRoutes);
+
+// 4. 全局错误处理
+app.onError((err, c) => {
+ const statusCode = (err as any).statusCode || (err as any).status || 500;
+
+ // 特殊处理: WebDAV list 接口如果返回 404,说明目录不存在,视为无备份,返回空列表
+ if (c.req.path.includes('/webdav/list') && (Number(statusCode) === 404 || err.message.includes('404'))) {
+ return c.json({ success: true, backups: [] });
+ }
+
+ console.error(`[Server Error] ${err.message}`);
+ return c.json({
+ success: false,
+ error: err.message || 'Internal Server Error'
+ }, statusCode);
+});
+
+// 5. 404 处理
+app.notFound((c) => {
+ return c.json({ success: false, error: 'API Not Found' }, 404);
+});
+
+// 6. 导出默认处理函数,实现前后端路由分发
+export default {
+ async fetch(request: Request, env: Bindings, ctx: any) {
+ const url = new URL(request.url);
+
+ // 识别 API 请求前缀
+ if (url.pathname.startsWith('/api')) {
+ return app.fetch(request, env, ctx);
+ }
+
+ // 非 API 请求全部交给静态资源 (Frontend)
+ return env.ASSETS.fetch(request);
+ }
+};
\ No newline at end of file
diff --git a/backend/src/routes/accounts.ts b/backend/src/routes/accounts.ts
new file mode 100644
index 0000000..a90bc34
--- /dev/null
+++ b/backend/src/routes/accounts.ts
@@ -0,0 +1,420 @@
+import { Hono } from 'hono';
+import { EnvBindings, AppError, SECURITY_CONFIG } from '../config';
+import { authMiddleware, sanitizeInput, validateBase32Secret, validateServiceName, validateAccountName, parseOTPAuthURI } from '../utils/helper';
+import { encryptData, decryptData, generateTOTP } from '../utils/crypto';
+
+// 定义带有环境变量和用户变量的 Hono 实例
+const accounts = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
+
+// 🛡️ 为这下面所有的路由挂载鉴权中间件 (必须登录才能访问)
+accounts.use('*', authMiddleware);
+
+// 辅助:加密单个字段并序列化为字符串存储
+async function encryptField(data: any, key: string) {
+ const encrypted = await encryptData(data, key);
+ return JSON.stringify(encrypted);
+}
+
+// 辅助:反序列化并解密单个字段
+async function decryptField(encryptedStr: string, key: string) {
+ try {
+ const encryptedObj = JSON.parse(encryptedStr);
+ return await decryptData(encryptedObj, key);
+ } catch (e) {
+ console.error('Decryption failed', e);
+ return null;
+ }
+}
+
+// ==========================================
+// 1. 获取所有账号
+// ==========================================
+accounts.get('/', async (c) => {
+ const pageStr = c.req.query('page');
+ const limitStr = c.req.query('limit');
+ const search = c.req.query('search') || '';
+ const page = pageStr ? parseInt(pageStr) : 1;
+ const limit = limitStr ? parseInt(limitStr) : 50; // 恢复默认 50 条,配合自动迁移策略
+
+ let whereClause = '';
+ let params: any[] = [];
+
+ if (search) {
+ whereClause = " WHERE (service LIKE ? OR account LIKE ? OR category LIKE ?)";
+ const likeTerm = `%${search}%`;
+ params.push(likeTerm, likeTerm, likeTerm);
+ }
+
+ const countResult = await c.env.DB.prepare(`SELECT COUNT(*) as total FROM accounts${whereClause}`).bind(...params).first();
+ const total = countResult?.total as number || 0;
+
+ const query = `SELECT * FROM accounts${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
+ params.push(limit, (page - 1) * limit);
+
+ const { results } = await c.env.DB.prepare(query).bind(...params).all();
+
+ const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
+
+ // 并行解密所有账号的 secret
+ const accountsList = await Promise.all(results.map(async (row: any) => {
+ const secret = await decryptField(row.secret, key);
+
+ // 🚀 [自动迁移] 检查是否为旧格式 (含 salt),如果是则异步升级为极速格式
+ try {
+ const raw = JSON.parse(row.secret);
+ if (raw.salt && raw.salt.length > 0) {
+ // 重新加密 (encryptField 现已使用极速模式)
+ const newSecret = await encryptField(secret, key);
+ // 异步更新数据库 (不阻塞响应)
+ c.executionCtx.waitUntil(
+ c.env.DB.prepare("UPDATE accounts SET secret = ? WHERE id = ?")
+ .bind(newSecret, row.id)
+ .run()
+ );
+ }
+ } catch (e) { /* ignore error */ }
+
+ return {
+ id: row.id,
+ service: row.service,
+ account: row.account,
+ category: row.category,
+ secret: secret || '', // 解密失败则为空
+ digits: row.digits,
+ period: row.period,
+ createdAt: row.created_at
+ };
+ }));
+
+ const response: any = { success: true, accounts: accountsList };
+
+ response.pagination = {
+ page,
+ limit,
+ total,
+ totalPages: Math.ceil(total / limit)
+ };
+
+ return c.json(response);
+});
+
+// ==========================================
+// 2. 添加新账号
+// ==========================================
+accounts.post('/', async (c) => {
+ const { service, category, account, secret, digits = 6, period = 30 } = await c.req.json();
+ const user = c.get('user');
+
+ if (!validateServiceName(service) || !validateAccountName(account) || !validateBase32Secret(secret)) {
+ throw new AppError('Invalid input format', 400);
+ }
+ if (![6, 8].includes(digits) || ![30, 60].includes(period)) {
+ throw new AppError('Invalid digits or period', 400);
+ }
+
+ // D1: 查重
+ const existing = await c.env.DB.prepare(
+ "SELECT id FROM accounts WHERE service = ? AND account = ?"
+ ).bind(service, account).first();
+
+ if (existing) throw new AppError('Account already exists', 409);
+
+ const id = crypto.randomUUID();
+ const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
+ const secretEncrypted = await encryptField(secret.replace(/\s/g, '').toUpperCase(), key);
+
+ // D1: 插入
+ await c.env.DB.prepare(
+ `INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ ).bind(
+ id, sanitizeInput(service, 50), sanitizeInput(account, 100), category ? sanitizeInput(category, 30) : '',
+ secretEncrypted, digits, period, Date.now(), user.username
+ ).run();
+
+ const newAccount = { id, service, account, category, digits, period };
+
+ return c.json({ success: true, account: { ...newAccount, secret: '[PROTECTED]' } });
+});
+
+// ==========================================
+// 3. 修改账号信息
+// ==========================================
+accounts.put('/:id', async (c) => {
+ const id = c.req.param('id');
+ const { service, category, account } = await c.req.json();
+ const user = c.get('user');
+
+ if (!validateServiceName(service) || !validateAccountName(account)) {
+ throw new AppError('Invalid input format', 400);
+ }
+
+ const result = await c.env.DB.prepare(
+ `UPDATE accounts
+ SET service = ?, account = ?, category = ?, updated_at = ?, updated_by = ?
+ WHERE id = ?`
+ ).bind(
+ sanitizeInput(service, 50), sanitizeInput(account, 100), category ? sanitizeInput(category, 30) : '',
+ Date.now(), user.username, id
+ ).run();
+
+ if (!result.meta.changes) throw new AppError('Account not found', 404);
+
+ return c.json({ success: true, message: 'Account updated successfully' });
+});
+
+// ==========================================
+// 4. 删除指定账号
+// ==========================================
+accounts.delete('/:id', async (c) => {
+ const id = c.req.param('id');
+ const result = await c.env.DB.prepare("DELETE FROM accounts WHERE id = ?").bind(id).run();
+
+ if (!result.meta.changes) throw new AppError('Account not found', 404);
+
+ return c.json({ success: true, message: 'Account deleted successfully' });
+});
+
+// ==========================================
+// 5. 清空所有账号
+// ==========================================
+accounts.delete('/clear-all', async (c) => {
+ await c.env.DB.prepare("DELETE FROM accounts").run();
+ return c.json({ success: true, message: 'All accounts cleared' });
+});
+
+// ==========================================
+// 6. 核心功能:生成 TOTP 验证码
+// ==========================================
+accounts.post('/generate-totp', async (c) => {
+ const { secret, period = 30, digits = 6 } = await c.req.json();
+
+ if (!validateBase32Secret(secret)) throw new AppError('Invalid secret format', 400);
+ if (![6, 8].includes(digits) || ![30, 60].includes(period)) throw new AppError('Invalid parameters', 400);
+
+ const code = await generateTOTP(secret, period, digits);
+ return c.json({ success: true, code });
+});
+
+// ==========================================
+// 7. 扫码支持:解析 TOTP URI
+// ==========================================
+accounts.post('/parse-uri', async (c) => {
+ const { uri } = await c.req.json();
+ if (!uri) throw new AppError('URI is required', 400);
+
+ const account = parseOTPAuthURI(uri);
+ if (!account) throw new AppError('Invalid OTP Auth URI', 400);
+
+ return c.json({ success: true, account });
+});
+
+// ==========================================
+// 8. 从扫码结果添加账户 (修复架构师遗漏的接口)
+// ==========================================
+accounts.post('/add-from-uri', async (c) => {
+ const { uri, category } = await c.req.json();
+ const user = c.get('user');
+
+ if (!uri) throw new AppError('URI is required', 400);
+
+ const parsedAccount = parseOTPAuthURI(uri);
+ if (!parsedAccount) throw new AppError('Invalid OTP Auth URI', 400);
+
+ // D1: 查重
+ const existing = await c.env.DB.prepare(
+ "SELECT id FROM accounts WHERE service = ? AND account = ?"
+ ).bind(parsedAccount.issuer, parsedAccount.account).first();
+ if (existing) throw new AppError('该账号已经存在金库中', 409);
+
+ const id = crypto.randomUUID();
+ const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
+ const secretEncrypted = await encryptField(parsedAccount.secret, key);
+
+ await c.env.DB.prepare(
+ `INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ ).bind(
+ id, sanitizeInput(parsedAccount.issuer || 'Unknown', 50), sanitizeInput(parsedAccount.account || 'Unknown', 100),
+ category ? sanitizeInput(category, 30) : '', secretEncrypted, parsedAccount.digits, parsedAccount.period, Date.now(), user.username
+ ).run();
+
+ const newAccount = { id, service: parsedAccount.issuer, account: parsedAccount.account };
+
+ return c.json({ success: true, account: { ...newAccount, secret: '[PROTECTED]' } });
+});
+
+// ==========================================
+// 9. 加密导出金库数据
+// ==========================================
+accounts.post('/export-secure', async (c) => {
+ const { password } = await c.req.json();
+ if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) {
+ throw new AppError(`导出密码至少需要 ${SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH} 个字符`, 400);
+ }
+
+ const { results } = await c.env.DB.prepare("SELECT * FROM accounts").all();
+ const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
+
+ // 解密所有数据以准备导出
+ const plainAccounts = await Promise.all(results.map(async (row: any) => ({
+ service: row.service,
+ account: row.account,
+ category: row.category,
+ secret: await decryptField(row.secret, key),
+ digits: row.digits,
+ period: row.period
+ })));
+
+ // 组装标准导出格式
+ const exportData = {
+ version: "2.0",
+ app: "2fa-secure-manager",
+ encrypted: true,
+ timestamp: new Date().toISOString(),
+ accounts: plainAccounts
+ };
+
+ // 使用用户提供的密码进行 AES-GCM 加密
+ const encrypted = await encryptData(exportData, password);
+ const exportFile = {
+ version: "2.0", app: "2fa-secure-manager", encrypted: true,
+ timestamp: new Date().toISOString(), data: encrypted,
+ note: "This file is encrypted with your export password. Keep it safe!"
+ };
+
+ return c.json(exportFile);
+});
+
+// ==========================================
+// 10. 全能导入通道 (支持 加密/JSON/2FAS/文本)
+// ==========================================
+accounts.post('/import', async (c) => {
+ const { content, type, password } = await c.req.json();
+ const user = c.get('user');
+
+ if (!content || !type) throw new AppError('内容和类型不能为空', 400);
+
+ let validAccounts: any[] = [];
+
+ try {
+ if (type === 'encrypted') {
+ if (!password) throw new AppError('加密导入需要密码', 400);
+ const encryptedFile = JSON.parse(content);
+ const decryptedData = await decryptData(encryptedFile.data, password);
+ validAccounts = decryptedData.accounts || [];
+ } else if (type === 'json') {
+ const data = JSON.parse(content);
+ if (data.app && data.app.includes('2fauth') && Array.isArray(data.data)) {
+ validAccounts = data.data.map((item: any) => ({...item, category: ''}));
+ } else if (data.accounts) {
+ validAccounts = data.accounts;
+ } else if (Array.isArray(data)) {
+ validAccounts = data;
+ } else if (data.services) {
+ validAccounts = data.services.map((s: any) => ({
+ service: s.service || s.name, account: s.account || s.login,
+ secret: s.secret, digits: s.digits, period: s.period, category: s.group
+ }));
+ }
+ } else if (type === '2fas') {
+ const data = JSON.parse(content);
+ if (data.services) {
+ validAccounts = data.services.map((s: any) => ({
+ service: s.name, account: s.account || s.username,
+ secret: s.secret, digits: s.digits, period: s.period, category: s.category || s.group
+ }));
+ }
+ } else if (type === 'text') {
+ const lines = content.split('\n').filter((line: string) => line.trim());
+ for (const line of lines) {
+ if (line.trim().startsWith('otpauth://')) {
+ const parsed = parseOTPAuthURI(line.trim());
+ if (parsed) validAccounts.push({
+ service: parsed.issuer, account: parsed.account,
+ secret: parsed.secret, digits: parsed.digits, period: parsed.period, category: ''
+ });
+ }
+ }
+ }
+ } catch (e) {
+ throw new AppError('解析失败,请检查文件格式或密码是否正确', 400);
+ }
+
+ // 过滤有效账户并去重保存
+ let addedCount = 0;
+ const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
+
+ // D1 批量插入 (使用事务或批量执行)
+ const stmt = c.env.DB.prepare(
+ `INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ );
+ const batch = [];
+
+ for (const acc of validAccounts) {
+ if (acc.service && acc.account && validateBase32Secret(acc.secret)) {
+ // 简单查重
+ const existing = await c.env.DB.prepare("SELECT id FROM accounts WHERE service = ? AND account = ?")
+ .bind(acc.service, acc.account).first();
+
+ if (!existing) {
+ const secretEncrypted = await encryptField(acc.secret.replace(/\s/g, '').toUpperCase(), key);
+ batch.push(stmt.bind(
+ crypto.randomUUID(), sanitizeInput(acc.service, 50), sanitizeInput(acc.account, 100),
+ acc.category ? sanitizeInput(acc.category, 30) : '', secretEncrypted,
+ acc.digits || 6, acc.period || 30, Date.now(), user.username
+ ));
+ addedCount++;
+ }
+ }
+ }
+
+ if (batch.length > 0) await c.env.DB.batch(batch);
+
+ return c.json({ success: true, count: addedCount, total: validAccounts.length, duplicates: validAccounts.length - addedCount });
+});
+
+// ==========================================
+// 11. 手动触发数据迁移 (旧格式 -> 极速格式)
+// ==========================================
+accounts.post('/migrate-crypto', async (c) => {
+ const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
+ const limit = 50; // 每次处理 50 条以防超时
+
+ // D1 JSON 查询:查找仍包含 salt 的记录
+ const { results } = await c.env.DB.prepare(
+ "SELECT id, secret FROM accounts WHERE json_extract(secret, '$.salt') IS NOT NULL LIMIT ?"
+ ).bind(limit).all();
+
+ if (results.length === 0) {
+ return c.json({ success: true, message: '所有数据已是最新格式', migrated: 0, remaining: 0 });
+ }
+
+ const updates = [];
+ for (const row of results) {
+ try {
+ const plain = await decryptField(row.secret as string, key);
+ const newSecret = await encryptField(plain, key);
+ updates.push(
+ c.env.DB.prepare("UPDATE accounts SET secret = ? WHERE id = ?").bind(newSecret, row.id)
+ );
+ } catch (e) { console.error(`Failed to migrate account ${row.id}`, e); }
+ }
+
+ if (updates.length > 0) await c.env.DB.batch(updates);
+
+ // 检查剩余数量
+ const remaining = await c.env.DB.prepare(
+ "SELECT COUNT(*) as count FROM accounts WHERE json_extract(secret, '$.salt') IS NOT NULL"
+ ).first('count');
+
+ return c.json({
+ success: true,
+ migrated: updates.length,
+ remaining: remaining,
+ message: `已迁移 ${updates.length} 条数据,剩余 ${remaining} 条。请继续点击直到剩余为 0。`
+ });
+});
+
+export default accounts;
\ No newline at end of file
diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts
new file mode 100644
index 0000000..1764931
--- /dev/null
+++ b/backend/src/routes/auth.ts
@@ -0,0 +1,113 @@
+import { Hono } from 'hono';
+import { EnvBindings, AppError } from '../config';
+import { generateSecureJWT } from '../utils/crypto';
+
+const auth = new Hono<{ Bindings: EnvBindings }>();
+
+// 内部辅助函数:获取 GitHub 用户信息
+async function fetchGitHubUser(accessToken: string, oauthBaseUrl: string) {
+ const isGitHub = oauthBaseUrl.includes('github.com');
+ const url = isGitHub ? 'https://api.github.com/user' : `${oauthBaseUrl}/api/user`;
+
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `token ${accessToken}`,
+ 'Accept': 'application/json',
+ 'User-Agent': '2FA-Manager-Backend/1.0'
+ }
+ });
+
+ if (!response.ok) {
+ throw new AppError(`GitHub API error: ${response.status}`, 502);
+ }
+ return await response.json();
+}
+
+// ==========================================
+// 1. 获取授权地址 (前端调用此接口获取跳转链接)
+// ==========================================
+auth.get('/authorize', async (c) => {
+ const state = crypto.randomUUID();
+ const env = c.env;
+ const isGitHub = env.OAUTH_BASE_URL.includes('github.com');
+ const authPath = isGitHub ? '/login/oauth/authorize' : '/oauth2/authorize';
+
+ const params = new URLSearchParams({
+ client_id: env.OAUTH_CLIENT_ID,
+ redirect_uri: env.OAUTH_REDIRECT_URI, // 注意:后续在环境变量中,这里要填入 Vue 前端的地址
+ state: state,
+ scope: isGitHub ? 'read:user user:email' : 'openid profile email'
+ });
+
+ const authUrl = `${env.OAUTH_BASE_URL.replace(/\/$/, '')}${authPath}?${params}`;
+
+ // 返回 URL 和 State 让 Vue 前端存入 localStorage 后自行跳转
+ return c.json({ success: true, authUrl, state });
+});
+
+// ==========================================
+// 2. 核心逻辑:用 Code 换取系统的 JWT 令牌
+// ==========================================
+auth.post('/callback', async (c) => {
+ const { code } = await c.req.json();
+ const env = c.env;
+
+ if (!code) {
+ throw new AppError('Missing OAuth code', 400);
+ }
+
+ const isGitHub = env.OAUTH_BASE_URL.includes('github.com');
+ const tokenPath = isGitHub ? '/login/oauth/access_token' : '/oauth2/token';
+
+ // 去 GitHub 换取 Access Token
+ const tokenResponse = await fetch(`${env.OAUTH_BASE_URL.replace(/\/$/, '')}${tokenPath}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Accept': 'application/json',
+ 'User-Agent': '2FA-Manager-Backend/1.0'
+ },
+ body: new URLSearchParams({
+ client_id: env.OAUTH_CLIENT_ID,
+ client_secret: env.OAUTH_CLIENT_SECRET,
+ code: code,
+ redirect_uri: env.OAUTH_REDIRECT_URI,
+ })
+ });
+
+ const tokenData = await tokenResponse.json();
+ if (!tokenData.access_token) {
+ throw new AppError('Failed to exchange code for token', 502);
+ }
+
+ // 获取用户信息
+ const userData = await fetchGitHubUser(tokenData.access_token, env.OAUTH_BASE_URL);
+
+ // 严密的安全校验:比对 OAUTH_ID (改为比对用户名)
+ // GitHub API 返回的用户名在 login 字段,这里统一转小写比较以防大小写差异
+ const currentUsername = userData.login || userData.username;
+ if (!currentUsername || currentUsername.toLowerCase() !== env.OAUTH_ID.toLowerCase()) {
+ throw new AppError('Unauthorized user', 403);
+ }
+
+ // 签发我们系统的专属 JWT 令牌
+ const payload = {
+ userInfo: {
+ id: userData.id,
+ username: userData.login || userData.username || 'GitHub User',
+ email: userData.email,
+ avatar_template: userData.avatar_url || userData.avatar_template
+ }
+ };
+
+ const token = await generateSecureJWT(payload, env.JWT_SECRET);
+
+ return c.json({
+ success: true,
+ token,
+ userInfo: payload.userInfo
+ });
+});
+
+export default auth;
\ No newline at end of file
diff --git a/backend/src/routes/webdav.ts b/backend/src/routes/webdav.ts
new file mode 100644
index 0000000..9f0f983
--- /dev/null
+++ b/backend/src/routes/webdav.ts
@@ -0,0 +1,310 @@
+import { Hono } from 'hono';
+import { createClient } from 'webdav';
+import { EnvBindings, AppError, SECURITY_CONFIG } from '../config';
+import { authMiddleware, sanitizeInput } from '../utils/helper';
+import { encryptData, decryptData } from '../utils/crypto';
+
+const webdav = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
+
+// 🛡️ 挂载鉴权中间件
+webdav.use('*', authMiddleware);
+
+// 辅助:加密单个字段并序列化为字符串存储
+async function encryptField(data: any, key: string) {
+ const encrypted = await encryptData(data, key);
+ return JSON.stringify(encrypted);
+}
+
+// 辅助:反序列化并解密单个字段
+async function decryptField(encryptedStr: string, key: string) {
+ try {
+ const encryptedObj = JSON.parse(encryptedStr);
+ return await decryptData(encryptedObj, key);
+ } catch (e) {
+ console.error('Decryption failed', e);
+ return null;
+ }
+}
+
+// 内部辅助:创建 WebDAV 客户端
+function getWebDavClient(config: any) {
+ if (!config || !config.url || !config.username || !config.password) {
+ throw new AppError('WebDAV configuration is incomplete', 400);
+ }
+ return createClient(config.url, {
+ username: config.username,
+ password: config.password
+ });
+}
+
+// 内部辅助:生成备份文件名
+function generateBackupFilename(dir: string) {
+ const date = new Date().toISOString().replace(/[:.]/g, '-');
+ const cleanDir = dir.endsWith('/') ? dir.slice(0, -1) : dir;
+ return `${cleanDir}/2fa-backup-${date}.json`;
+}
+
+// ==========================================
+// 1. 获取 WebDAV 配置 (解密后返回给前端)
+// ==========================================
+webdav.get('/configs', async (c) => {
+ const { results } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all();
+ if (results.length === 0) return c.json({ success: true, configs: [] });
+
+ const row: any = results[0];
+ const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
+ const password = await decryptField(row.password, key);
+
+ // 🚀 [自动迁移] 升级 WebDAV 密码加密格式
+ try {
+ const raw = JSON.parse(row.password);
+ if (raw.salt && raw.salt.length > 0) {
+ const newPass = await encryptField(password, key);
+ c.executionCtx.waitUntil(
+ c.env.DB.prepare("UPDATE webdav_configs SET password = ? WHERE id = ?").bind(newPass, row.id).run()
+ );
+ }
+ } catch (e) {}
+
+ const configs = [{
+ url: row.url,
+ username: row.username,
+ password: password,
+ saveDir: row.save_dir
+ }];
+ return c.json({ success: true, configs });
+});
+
+// ==========================================
+// 2. 保存 WebDAV 配置 (加密后存入 KV)
+// ==========================================
+webdav.post('/configs', async (c) => {
+ const { configs } = await c.req.json();
+
+ if (!Array.isArray(configs)) {
+ throw new AppError('Invalid format: configs must be an array', 400);
+ }
+
+ const config = configs[0];
+ const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
+ const passwordEncrypted = await encryptField(config.password, key);
+
+ await c.env.DB.batch([
+ c.env.DB.prepare("DELETE FROM webdav_configs"),
+ c.env.DB.prepare("INSERT INTO webdav_configs (url, username, password, save_dir, updated_at) VALUES (?, ?, ?, ?, ?)").bind(config.url, config.username, passwordEncrypted, config.saveDir, Date.now())
+ ]);
+
+ return c.json({ success: true, message: 'Configuration saved securely' });
+});
+
+// ==========================================
+// 3. 测试连接 (示例桩代码)
+// ==========================================
+webdav.post('/test', async (c) => {
+ // 前端通常会把当前填写的配置传过来测试,所以这里直接用 body 里的
+ const config = await c.req.json();
+
+ try {
+ const client = getWebDavClient(config);
+ // 尝试读取根目录来验证凭证
+ await client.getDirectoryContents('/');
+ return c.json({ success: true, message: 'Connection successful' });
+ } catch (error: any) {
+ console.error('WebDAV Test Error:', error);
+ throw new AppError(`Connection failed: ${error.message}`, 400);
+ }
+});
+
+// ==========================================
+// 4. 获取备份文件列表
+// ==========================================
+webdav.post('/list', async (c) => {
+ const { results } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all();
+ if (results.length === 0) throw new AppError('No WebDAV config found', 404);
+
+ const row: any = results[0];
+ const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
+ const password = await decryptField(row.password, key);
+
+ // 🚀 [自动迁移] 升级 WebDAV 密码加密格式 (在 list 接口也做检查)
+ try {
+ const raw = JSON.parse(row.password);
+ if (raw.salt && raw.salt.length > 0) {
+ const newPass = await encryptField(password, key);
+ c.executionCtx.waitUntil(
+ c.env.DB.prepare("UPDATE webdav_configs SET password = ? WHERE id = ?").bind(newPass, row.id).run()
+ );
+ }
+ } catch (e) {}
+
+ const configs = [{
+ url: row.url,
+ username: row.username,
+ password: password,
+ saveDir: row.save_dir || '/'
+ }];
+
+ if (configs.length === 0) throw new AppError('No WebDAV config found', 404);
+
+ // 默认使用第一个配置
+ const config = configs[0];
+ const client = getWebDavClient(config);
+
+ try {
+ const items = await client.getDirectoryContents(config.saveDir);
+ // 过滤出我们的备份文件
+ const backups = (items as any[])
+ .filter(item => item.type === 'file' && item.basename.startsWith('2fa-backup-') && item.basename.endsWith('.json'))
+ .map(item => {
+ // 尝试获取时间:优先用服务器返回的 lastmod,如果没有则尝试从文件名解析 (文件名包含 ISO 时间戳)
+ let displayTime = item.lastmod;
+ if (!displayTime) {
+ try {
+ // 文件名格式: 2fa-backup-2024-03-20T10-00-00-000Z.json
+ // 简单提取中间的时间部分用于显示
+ const match = item.basename.match(/2fa-backup-(.+)\.json/);
+ if (match) displayTime = match[1].replace(/-/g, ':').replace('T', ' ');
+ } catch (e) {}
+ }
+
+ return {
+ name: item.basename,
+ filename: item.basename, // 兼容前端可能使用的 filename 字段
+ size: item.size,
+ lastMod: displayTime,
+ lastModified: displayTime // 兼容前端可能使用的 lastModified 字段
+ };
+ })
+ .sort((a, b) => b.name.localeCompare(a.name)); // 按时间倒序
+
+ return c.json({ success: true, backups });
+ } catch (error: any) {
+ throw new AppError(`Failed to list backups: ${error.message}`, 500);
+ }
+});
+
+// ==========================================
+// 5. 导出当前金库到 WebDAV
+// ==========================================
+webdav.post('/export', async (c) => {
+ const { password } = await c.req.json();
+ if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) {
+ throw new AppError(`导出密码至少需要 ${SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH} 个字符`, 400);
+ }
+
+ // 获取 WebDAV 配置
+ const { results: configResults } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all();
+ if (configResults.length === 0) throw new AppError('No WebDAV config found', 404);
+ const configRow: any = configResults[0];
+ const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
+ const webdavPassword = await decryptField(configRow.password, key);
+ const webdavConfig = { url: configRow.url, username: configRow.username, password: webdavPassword, saveDir: configRow.save_dir || '/' };
+
+ // 1. 从 D1 获取并解密当前数据
+ const { results: accountResults } = await c.env.DB.prepare("SELECT * FROM accounts").all();
+
+ const accounts = await Promise.all(accountResults.map(async (row: any) => ({
+ service: row.service,
+ account: row.account,
+ category: row.category,
+ secret: await decryptField(row.secret, key),
+ digits: row.digits,
+ period: row.period
+ })));
+
+ // 2. 准备导出数据结构 (清洗数据,仅保留必要字段)
+ const exportPayload = {
+ version: "2.0",
+ app: "2fa-secure-manager",
+ encrypted: true,
+ timestamp: new Date().toISOString(),
+ accounts: accounts.map((acc: any) => ({
+ service: acc.service, category: acc.category, account: acc.account,
+ secret: acc.secret, digits: acc.digits, period: acc.period
+ }))
+ };
+
+ // 3. 使用用户密码重新加密
+ const userEncrypted = await encryptData(exportPayload, password);
+ const fileContent = JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined });
+
+ // 4. 上传到 WebDAV
+ const client = getWebDavClient(webdavConfig);
+ const filename = generateBackupFilename(webdavConfig.saveDir);
+
+ // 尝试创建目录(如果不存在)
+ if (webdavConfig.saveDir !== '/' && await client.exists(webdavConfig.saveDir) === false) {
+ await client.createDirectory(webdavConfig.saveDir);
+ }
+
+ await client.putFileContents(filename, fileContent);
+
+ return c.json({ success: true, message: 'Backup uploaded successfully', filename });
+});
+
+// ==========================================
+// 6. 从 WebDAV 恢复备份
+// ==========================================
+webdav.post('/restore', async (c) => {
+ let body;
+ try {
+ body = await c.req.json();
+ } catch (e) {
+ throw new AppError('Invalid JSON body', 400);
+ }
+
+ // 规范化参数
+ const filename = body.filename || body.name;
+ const password = body.password;
+
+ if (!filename) throw new AppError('Filename is required', 400);
+ if (!password) throw new AppError('Decryption password is required', 400);
+
+ const { results: configResults } = await c.env.DB.prepare("SELECT * FROM webdav_configs LIMIT 1").all();
+ if (configResults.length === 0) throw new AppError('No WebDAV config found', 404);
+ const configRow: any = configResults[0];
+ const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
+ const webdavPassword = await decryptField(configRow.password, key);
+ const saveDir = configRow.save_dir || '/';
+
+ const client = getWebDavClient({ url: configRow.url, username: configRow.username, password: webdavPassword });
+
+ // 1. 下载文件
+ // 拼接完整路径,因为 filename 只是文件名
+ const fullPath = saveDir.endsWith('/') ? `${saveDir}${filename}` : `${saveDir}/${filename}`;
+ const content = await client.getFileContents(fullPath, { format: 'text' });
+
+ // 2. 使用用户密码解密
+ let accounts = [];
+ try {
+ const backupFile = JSON.parse(content as string);
+ // 兼容性检查:如果是旧版直接上传的 KV 数据(没有 data 字段),则无法用密码解密,直接报错
+ if (!backupFile.data) throw new Error('Legacy format');
+
+ const decrypted = await decryptData(backupFile.data, password);
+ accounts = decrypted.accounts || [];
+ } catch (e) {
+ throw new AppError('解密失败:密码错误或文件格式不兼容', 400);
+ }
+
+ // 3. 覆盖写入 D1 (先清空,再批量插入)
+ await c.env.DB.prepare("DELETE FROM accounts").run();
+
+ const stmt = c.env.DB.prepare(
+ `INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ );
+ const batch = [];
+ for (const acc of accounts) {
+ const secretEncrypted = await encryptField(acc.secret, key);
+ batch.push(stmt.bind(
+ crypto.randomUUID(), sanitizeInput(acc.service, 50), sanitizeInput(acc.account, 100),
+ acc.category || '', secretEncrypted, acc.digits || 6, acc.period || 30, Date.now(), 'restore'
+ ));
+ }
+ if (batch.length > 0) await c.env.DB.batch(batch);
+
+ return c.json({ success: true, message: 'Restore successful' });
+});
+
+export default webdav;
\ No newline at end of file
diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts
new file mode 100644
index 0000000..7ff2103
--- /dev/null
+++ b/backend/src/utils/crypto.ts
@@ -0,0 +1,153 @@
+import { BASE32_CHARS, SECURITY_CONFIG } from '../config';
+
+// ==========================================
+// 1. Base32 与 TOTP 核心算法
+// ==========================================
+export function base32Decode(encoded: string): Uint8Array {
+ const cleanInput = encoded.toUpperCase().replace(/[^A-Z2-7]/g, '');
+ const buffer = new Uint8Array(Math.floor(cleanInput.length * 5 / 8));
+ let bits = 0, value = 0, index = 0;
+
+ for (let i = 0; i < cleanInput.length; i++) {
+ const charValue = BASE32_CHARS.indexOf(cleanInput[i]);
+ if (charValue === -1) continue;
+ value = (value << 5) | charValue;
+ bits += 5;
+ if (bits >= 8) {
+ buffer[index++] = (value >>> (bits - 8)) & 255;
+ bits -= 8;
+ }
+ }
+ return buffer;
+}
+
+export async function hmacSHA1(key: Uint8Array | string, data: number): Promise {
+ const keyBuffer = typeof key === 'string' ? new TextEncoder().encode(key) : key;
+ const dataBuffer = new ArrayBuffer(8);
+ new DataView(dataBuffer).setBigUint64(0, BigInt(data), false);
+
+ const cryptoKey = await crypto.subtle.importKey('raw', keyBuffer, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, dataBuffer);
+ return new Uint8Array(signature);
+}
+
+export async function generateTOTP(secret: string | Uint8Array, timeStep = 30, digits = 6): Promise {
+ const time = Math.floor(Date.now() / 1000 / timeStep);
+ const secretBytes = typeof secret === 'string' ? base32Decode(secret) : secret;
+
+ const hmac = await hmacSHA1(secretBytes, time);
+ const offset = hmac[hmac.length - 1] & 0xf;
+
+ const code = (((hmac[offset] & 0x7f) << 24) |
+ ((hmac[offset + 1] & 0xff) << 16) |
+ ((hmac[offset + 2] & 0xff) << 8) |
+ (hmac[offset + 3] & 0xff)) % Math.pow(10, digits);
+
+ return code.toString().padStart(digits, '0');
+}
+
+// ==========================================
+// 2. JWT 签发与验证 (原生轻量实现)
+// ==========================================
+export async function generateSecureJWT(payload: Record, secret: string): Promise {
+ const header = { alg: 'HS256', typ: 'JWT', iat: Math.floor(Date.now() / 1000) };
+ const enhancedPayload = {
+ ...payload,
+ iat: Math.floor(Date.now() / 1000),
+ exp: Math.floor(Date.now() / 1000) + SECURITY_CONFIG.JWT_EXPIRY,
+ jti: crypto.randomUUID()
+ };
+
+ const headerB64 = btoa(JSON.stringify(header)).replace(/[+/=]/g, (m) => ({'+':'-','/':'_','=':''}[m as keyof typeof map] || m));
+ const payloadB64 = btoa(JSON.stringify(enhancedPayload)).replace(/[+/=]/g, (m) => ({'+':'-','/':'_','=':''}[m as keyof typeof map] || m));
+
+ const data = `${headerB64}.${payloadB64}`;
+ const encoder = new TextEncoder();
+ const cryptoKey = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(data));
+ const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/[+/=]/g, (m) => ({'+':'-','/':'_','=':''}[m as keyof typeof map] || m));
+
+ return `${data}.${signatureB64}`;
+}
+
+export async function verifySecureJWT(token: string, secret: string): Promise {
+ try {
+ const [headerB64, payloadB64, signatureB64] = token.split('.');
+ if (!headerB64 || !payloadB64 || !signatureB64) return null;
+
+ const data = `${headerB64}.${payloadB64}`;
+ const encoder = new TextEncoder();
+
+ const cryptoKey = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
+ const signatureBytes = Uint8Array.from(atob(signatureB64.replace(/[-_]/g, (m) => ({'-':'+','_':'/'}[m as keyof typeof map] || m))), c => c.charCodeAt(0));
+ const isValid = await crypto.subtle.verify('HMAC', cryptoKey, signatureBytes, encoder.encode(data));
+
+ if (isValid) {
+ const payload = JSON.parse(atob(payloadB64.replace(/[-_]/g, (m) => ({'-':'+','_':'/'}[m as keyof typeof map] || m))));
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null;
+ return payload;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+// ==========================================
+// 3. AES-GCM 数据加解密 (用于保护 KV 里的 2FA 密钥)
+// ==========================================
+// ⚡️ 极速模式:直接使用密钥的哈希值作为 AES 密钥 (无需 PBKDF2 慢速派生)
+async function getFastKey(secret: string): Promise {
+ const encoder = new TextEncoder();
+ // 使用 SHA-256 确保密钥长度固定为 256 位 (32字节)
+ const keyBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(secret));
+ return crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
+}
+
+// 🐢 兼容模式:PBKDF2 (用于解密旧数据)
+export async function deriveKey(password: string, salt: Uint8Array): Promise {
+ const encoder = new TextEncoder();
+ const keyMaterial = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveKey']);
+ return await crypto.subtle.deriveKey(
+ { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' },
+ keyMaterial,
+ { name: 'AES-GCM', length: 256 },
+ false,
+ ['encrypt', 'decrypt']
+ );
+}
+
+export async function encryptData(data: any, masterKey: string): Promise<{ encrypted: number[], iv: number[], salt?: number[] }> {
+ const encoder = new TextEncoder();
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+
+ // 🚀 默认使用极速模式 (不再生成 salt,不再进行 PBKDF2 计算)
+ const key = await getFastKey(masterKey);
+
+ const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoder.encode(JSON.stringify(data)));
+ return {
+ encrypted: Array.from(new Uint8Array(encrypted)),
+ iv: Array.from(iv)
+ // salt: undefined <-- 新数据不再包含 salt
+ };
+}
+
+export async function decryptData(encryptedData: { encrypted: number[], iv: number[], salt?: number[] }, masterKey: string): Promise {
+ const decoder = new TextDecoder();
+ const iv = new Uint8Array(encryptedData.iv);
+ const encrypted = new Uint8Array(encryptedData.encrypted);
+
+ let key: CryptoKey;
+
+ // 🔄 智能兼容:如果发现数据中有 salt,说明是旧数据,使用 PBKDF2 解密
+ if (encryptedData.salt && encryptedData.salt.length > 0) {
+ const salt = new Uint8Array(encryptedData.salt);
+ key = await deriveKey(masterKey, salt);
+ } else {
+ // 🚀 否则使用极速模式解密
+ key = await getFastKey(masterKey);
+ }
+
+ const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted);
+ return JSON.parse(decoder.decode(decrypted));
+}
\ No newline at end of file
diff --git a/backend/src/utils/helper.ts b/backend/src/utils/helper.ts
new file mode 100644
index 0000000..3a6722c
--- /dev/null
+++ b/backend/src/utils/helper.ts
@@ -0,0 +1,81 @@
+import { Context, Next } from 'hono';
+import { SECURITY_CONFIG, AppError, EnvBindings } from '../config';
+import { verifySecureJWT } from './crypto';
+
+// 1. 输入净化与验证
+export function sanitizeInput(input: any, maxLength = SECURITY_CONFIG.MAX_INPUT_LENGTH): string {
+ if (typeof input !== 'string') return '';
+ return input.replace(/[<>"'&\x00-\x1F\x7F]/g, '').trim().substring(0, maxLength);
+}
+
+export function validateBase32Secret(secret: any): boolean {
+ if (!secret || typeof secret !== 'string') return false;
+ const cleaned = secret.replace(/\s/g, '').toUpperCase();
+ return /^[A-Z2-7]+=*$/.test(cleaned) && cleaned.length >= 16;
+}
+
+export function validateServiceName(service: any): boolean {
+ const cleaned = sanitizeInput(service, 50);
+ return cleaned.length >= 1 && cleaned.length <= 50;
+}
+
+export function validateAccountName(account: any): boolean {
+ const cleaned = sanitizeInput(account, 100);
+ return cleaned.length >= 1 && cleaned.length <= 100;
+}
+
+// 2. 解析 二维码 URI
+export function parseOTPAuthURI(uri: string) {
+ try {
+ if (!uri || typeof uri !== 'string' || uri.length > 1000) return null;
+ const url = new URL(uri);
+ if (url.protocol !== 'otpauth:') return null;
+
+ const type = url.hostname;
+ if (type !== 'totp' && type !== 'hotp') return null;
+
+ const label = decodeURIComponent(url.pathname.substring(1));
+ const params = new URLSearchParams(url.search);
+
+ const secret = params.get('secret');
+ if (!validateBase32Secret(secret)) return null;
+
+ const [issuer, account] = label.includes(':') ? label.split(':', 2) : ['', label];
+ const digits = parseInt(params.get('digits') || '6');
+ const period = parseInt(params.get('period') || '30');
+
+ if (digits < 6 || digits > 8 || period < 15 || period > 300) return null;
+
+ return {
+ type,
+ label: sanitizeInput(label, 100),
+ issuer: sanitizeInput(params.get('issuer') || issuer, 50),
+ account: sanitizeInput(account || label, 100),
+ secret: secret!,
+ algorithm: (params.get('algorithm') || 'SHA1').toUpperCase(),
+ digits,
+ period
+ };
+ } catch {
+ return null;
+ }
+}
+
+// 3. Hono JWT 鉴权中间件
+export async function authMiddleware(c: Context<{ Bindings: EnvBindings, Variables: { user: any } }>, next: Next) {
+ const authHeader = c.req.header('Authorization');
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ throw new AppError('Unauthorized: Missing or invalid token', 401);
+ }
+
+ const token = authHeader.substring(7);
+ const payload = await verifySecureJWT(token, c.env.JWT_SECRET);
+
+ if (!payload || !payload.userInfo) {
+ throw new AppError('Unauthorized: Token expired or invalid', 401);
+ }
+
+ // 验证通过,将用户信息挂载到上下文,供后续路由使用
+ c.set('user', payload.userInfo);
+ await next();
+}
\ No newline at end of file
diff --git a/backend/tsconfig.json b/backend/tsconfig.json
new file mode 100644
index 0000000..2334537
--- /dev/null
+++ b/backend/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "lib": [
+ "ESNext"
+ ],
+ "jsx": "react-jsx",
+ "jsxImportSource": "hono/jsx"
+ },
+}
\ No newline at end of file
diff --git a/backend/wrangler.jsonc b/backend/wrangler.jsonc
new file mode 100644
index 0000000..b45ad1d
--- /dev/null
+++ b/backend/wrangler.jsonc
@@ -0,0 +1,39 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "2fauth-worker",
+ "main": "src/index.ts",
+ "compatibility_date": "2024-04-01",
+
+ // ====== 静态资源托管 ======
+ "assets": {
+ "binding": "ASSETS",
+ "directory": "../frontend/dist",
+ "not_found_handling": "single-page-application",
+ "run_worker_first": true
+ },
+
+ // ====== 自动构建前端 ======
+ "build": {
+ "command": "npm run build --prefix ../frontend"
+ },
+ "d1_databases": [
+ {
+ "binding": "DB",
+ "database_name": "2fauth-db",
+ "database_id": "d2ca3b30-408b-4529-9f09-eb1d3ade0dd5"
+ }
+ ],
+
+ // ====== 本地开发环境配置 ======
+ "env": {
+ "dev": {
+ "d1_databases": [
+ {
+ "binding": "DB",
+ "database_name": "2fauth-db-dev",
+ "database_id": "9e591736-5dbb-4f09-8769-0270d7257731"
+ }
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..05f67a5
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ 2FAuth - 您的专属云端双因素认证管家!
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..8d5b285
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@element-plus/icons-vue": "^2.3.2",
+ "element-plus": "^2.13.2",
+ "jsqr": "^1.4.0",
+ "vue": "^3.5.25",
+ "vue-router": "^5.0.2"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^6.0.2",
+ "vite": "^7.3.1"
+ }
+}
diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg
new file mode 100644
index 0000000..051e07f
--- /dev/null
+++ b/frontend/public/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
new file mode 100644
index 0000000..183b9ce
--- /dev/null
+++ b/frontend/src/App.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/assets/logo.svg b/frontend/src/assets/logo.svg
new file mode 100644
index 0000000..051e07f
--- /dev/null
+++ b/frontend/src/assets/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/components/DataMigration.vue b/frontend/src/components/DataMigration.vue
new file mode 100644
index 0000000..7a4c695
--- /dev/null
+++ b/frontend/src/components/DataMigration.vue
@@ -0,0 +1,259 @@
+
+